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 67955d377d..cdee6ab259 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08 -2026-07-08-agent-scope-contexts.zh.md: 35e725e43d402b048daf12c3b4be384b3fd2d2ce +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +2026-07-08-agent-scope-contexts.md: 5e09bdbcae1e57e6b65eb7d1720a6e7a7f758a9f +2026-07-08-agent-scope-contexts.zh.md: 4714045f28e0386a3a53b53437d063462e75a9f1 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md index e4c076189a..5e09bdbcae 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -108,11 +108,11 @@ A listener registered with `{ global: true }` deliberately bypasses contextual a ### Creation publishes last and disposal revokes last -`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. +`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, synchronously invoke its optional `AgentSetupCommit`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. The commit lets mutable provisioning revalidate at the exact publication boundary after every setup await; a throw rolls the private transaction back before either identity is announced, while revocation after a successful commit is ordinary live teardown. An optional creation signal cancels work only while create or resume is pending. After the promise resolves, the returned `AgentHandle` owns explicit disposal. -If loading, setup, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. +If loading, setup, the optional setup commit, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. `AgentHandle.dispose()` reverses the boundary. It deactivates creation or driving, waits for synchronous publication to unwind, stops and drains the driver and final session flushes, detaches the agent and session, and finally disposes the scope. Repeated or racing disposal requests join one completion promise. @@ -122,12 +122,14 @@ The calling Cordis context and the concrete AgentLoop factory are structural co- 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"] + setup --> setupCommit["Commit optional mutable provisioning"] + setupCommit --> 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 + setupCommit -->|"revalidation failure 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"] @@ -166,6 +168,6 @@ Parentage describes lifetime and conversation lineage, not a universal merge pol ## Consequences -Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup is atomic from an observer's perspective, and teardown preserves local behavior until work stops. +Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup and its optional publication commit are atomic from an observer's perspective, and teardown preserves local behavior until work stops. The cost is explicit subject selection, asynchronous programmatic creation, and service-specific scope adoption. Flat registration scope is intentionally not authority, and subagent composition controls remain a separate feature rather than hidden scope semantics. 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 35e725e43d..4714045f28 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 @@ -108,11 +108,11 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 ### 创建最后发布,dispose 最后撤销 -`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。 +`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,同步调用其可选的 `AgentSetupCommit`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。该提交操作让可变的配置状态在所有 setup 的 await 均结算后,于确切的发布边界重新校验;若其抛出异常,则会在公告任何一个身份前回滚私有事务,而成功提交后的撤销属于普通的实时拆卸。 可选的创建信号仅在创建或恢复挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。 -如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 +如果加载、setup、可选的 setup 提交、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 `AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷写,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。 @@ -122,12 +122,14 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 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"] + setup --> setupCommit["Commit optional mutable provisioning"] + setupCommit --> 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 + setupCommit -->|"revalidation failure 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"] @@ -166,6 +168,6 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 ## 后果 -贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。 +贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看,setup 及其可选的发布提交是原子的,拆除则保留本地行为直到工作停止。 代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等同于权限,subagent 组合控制作为独立功能存在,而非隐藏的作用域语义。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml new file mode 100644 index 0000000000..c8784f5991 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md +2026-08-02-goal-round-wrapup-message.md: c6bc3d5912b0789efde55880c2be892e98e34a5b +2026-08-02-goal-round-wrapup-message.zh.md: 0a504b4dcc61feb932775b3d9ffd8424f3b0597d diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md new file mode 100644 index 0000000000..c6bc3d5912 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md @@ -0,0 +1,31 @@ +# Agent Note: Goal-round wrap-up message + +Status: implemented + +English | [中文](2026-08-02-goal-round-wrapup-message.zh.md) + +## Problem + +An autonomous goal round that reported `update_goal` `complete` or `blocked` concluded the physical turn at the tool result, so the model never spoke after the call. Sessions ended on a bare `update_goal` card, and internal testers read that as the agent stopping mid-sentence: the model's pre-call text routinely announces a report ("goal achieved, marking complete:") that never arrives, because the standard tool-use expectation is one more assistant message after a tool result and neither the goal-round prompt nor the tool description said the call was terminal. The hard stop came from the [goal-tool decision](../feature/2026-07-19-model-facing-goal-tools.md), whose turn-stop clause this note supersedes. + +## Decision + +A goal-round `complete` or `blocked` success no longer calls `concludeTurn()`. Instead the tool defers one wrap-up context onto its own result: a `{ kind: 'plugin', plugin: 'tool-goal' }`-sourced user message carrying a ``/`` instruction to write a grounded closing message to the user and call no more tools. The turn then ends through the agent loop's ordinary no-tool-calls stop, so no new loop primitive exists and steering semantics are untouched. Direct-human mutations remain uninstructed exactly as before. The cost is one additional model request per goal lifecycle, not per round. + +The instruction wording was selected by A/B sampling on `deepseek-v4-pro` with a reconstructed goal-round transcript: a structured instruction (outcome, verification, artifacts, next steps) consistently beat a minimal "summarize" one on completeness; adding a session-grounding clause shifted unsupported detail from asserted fact to hedged suggestion; and the no-instruction control produced high-variance closings, including confidently fabricated file-level detail. + +Scripting the keyless proof required one snapshot-harness addition: `dsh-llm-replay` resolves `{{fromRequest:}}` placeholders in scripted entries against the live request, because a static sidecar cannot know the randomly minted goal id the model must echo into `update_goal`. + +## Verification + +`tool-goal` package tests pin the injected context (source, tag, objective, no-more-tools clause) and the absent `concludesTurn` for both terminal actions, plus the uninstructed direct-human pause and complete paths, at 100% file coverage. `llm-replay` unit tests pin the placeholder contract: last-match-wins capture, whole-match fallback, and loud failures for unmatched, invalid, and unterminated patterns. The new keyless ACP snapshot `goal-wrapup` drives the shipped application through create → round one → autonomous complete and asserts the plugin-sourced wrap-up injection, the same-turn closing assistant message, and the `completed` turn end in both the durable session log and the ACP stdout stream. + +## Alternatives considered + +- **Surface the completion text on the `update_goal` UI card** — rejected: `complete` carries no free text today, and adding a `summary` argument would route a user-facing report through tool arguments while still cutting off the model's natural post-result message. +- **Keep `concludeTurn()` and add a "one more text-only step" loop primitive** — rejected: new `agent-loop` machinery for behavior the ordinary stop already provides once nothing concludes the turn. +- **Instruct inside the tool result content** — rejected: the goal tools' canonical output is compact JSON consumed programmatically; a prose instruction block inside it would mix the model-facing contract with the tool's replayable value. + +## Consequences + +Every autonomous goal ends with a user-facing closing message instead of a bare tool card, at the cost of one model request per goal lifecycle. `concludeTurn()` keeps its loop semantics but loses its only first-party caller outside subagent structured output. Snapshot scenarios can now script values that only exist at run time via `{{fromRequest:...}}`, which unblocks keyless coverage of any echo-an-id tool flow, goal or otherwise. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md new file mode 100644 index 0000000000..0a504b4dcc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md @@ -0,0 +1,31 @@ +# Agent Note:Goal Round 收尾消息 + +Status: implemented + +[English](2026-08-02-goal-round-wrapup-message.md) | 中文 + +## 问题 + +自主 Goal Round 报告 `update_goal` `complete` 或 `blocked` 时,物理轮次在工具结果处直接终结,模型在调用之后再无发言机会。会话终止在一张裸的 `update_goal` 卡片上,内测同学的观感是 agent 话说到一半戛然而止:模型调用前的文本通常预告了一份汇报(“目标达成,标记完成:”)却永远没有下文,因为标准 tool-use 预期是工具结果之后还有一条 assistant 消息,而 Goal Round 提示词与工具描述都没有说明这次调用是终点。硬停止来自 [goal 工具决策](../feature/2026-07-19-model-facing-goal-tools.md),本 note 取代其中的轮次停止条款。 + +## 决策 + +Goal Round 的 `complete` 或 `blocked` 成功不再调用 `concludeTurn()`。工具改为在自己的结果上附带一条收尾上下文:以 `{ kind: 'plugin', plugin: 'tool-goal' }` 为 source 的 user 消息,携带 ``/`` 指令,要求模型向用户写出有依据的收尾消息且不再调用工具。之后轮次经由 agent loop 常规的无工具调用停止路径结束,因此不存在新的 loop 原语,steering 语义不受影响。人类直接变更保持原样、不注入指令。代价是每个 goal 生命周期一次额外模型请求,而非每轮一次。 + +指令措辞通过在 `deepseek-v4-pro` 上用重构的 Goal Round 转录做 A/B 采样选定:结构化指令(结果、验证、产物、后续)在完整度上稳定优于极简“总结一下”;补充“以会话内证据为准”的 grounding 条款让无依据细节从断言事实退为带保留的建议;而无指令对照组的收尾方差很大,包括言之凿凿的文件级细节编造。 + +为让 keyless 证明可脚本化,快照设施补了一项能力:`dsh-llm-replay` 会针对实时请求解析脚本条目中的 `{{fromRequest:}}` 占位符,因为静态伴随文件不可能预知模型必须回填进 `update_goal` 的随机生成 goal id。 + +## 验证 + +`tool-goal` 包测试钉住两个终态 action 注入的上下文(source、标签、objective、禁止再调工具条款)与不存在的 `concludesTurn`,以及人类直接 pause 与 complete 的不注入路径,文件覆盖率 100%。`llm-replay` 单元测试钉住占位符契约:最后一次匹配取胜的捕获、无捕获组时整体匹配回退,以及未匹配、非法、未闭合模式的明确报错。新增 keyless ACP 快照 `goal-wrapup` 驱动成品应用走完 create → 第一轮 → 自主 complete,并在持久会话日志与 ACP stdout 流中同时断言 plugin 来源的收尾注入、同轮内的收尾 assistant 消息与 `completed` 轮次结束。 + +## 曾考虑的替代方案 + +- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 如今不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。 +- **保留 `concludeTurn()` 并新增“再多一步纯文本”的 loop 原语** — 拒绝:为常规停止路径已经能提供的行为(只要没有结果终结轮次)增加新的 `agent-loop` 机制。 +- **把指令写进工具结果内容** — 拒绝:goal 工具的规范输出是被程序化消费的紧凑 JSON;在其中混入散文指令会把模型侧契约和工具的可回放值搅在一起。 + +## Consequences + +每个自主 goal 都以一条面向用户的收尾消息结束,而非一张裸工具卡片,代价是每个 goal 生命周期一次模型请求。`concludeTurn()` 保留其 loop 语义,但在 subagent 结构化输出之外失去了唯一的一方调用者。快照场景现在可以通过 `{{fromRequest:...}}` 脚本化只在运行时才存在的值,为任何“回显 id”类工具流程(不限于 goal)解锁 keyless 覆盖。 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 6cf16c04ad..1f787065ab 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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: bc4305af80bb13ceeff1888d489dcd8a00132f94 -2026-07-19-model-facing-goal-tools.zh.md: b07f62aa526902c4b2e9c081777a76ca53783d31 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +2026-07-19-model-facing-goal-tools.md: 18235c484194f5daf10556ebfc13bdc2d672be2e +2026-07-19-model-facing-goal-tools.zh.md: cc23a76e5faac2c203052d834ca0a87ca5dbed2a 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 bc4305af80..18235c4841 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 @@ -22,7 +22,7 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. UI 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 marks its tool result as concluding the physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not conclude the turn: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. +An autonomous goal round that successfully reports completion or blocking defers one wrap-up instruction onto its tool result so the model still addresses the user before the turn ends through the ordinary no-tool-calls stop; the original conclude-at-result stop is superseded by the [goal-round wrap-up decision](../bug-fix/2026-08-02-goal-round-wrapup-message.md). Direct-human mutations receive no instruction: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. ### Execution authority 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 b07f62aa52..cc23a76e5f 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 @@ -22,7 +22,7 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 -自主目标回合成功报告完成或阻塞后,其工具结果会被标记为结束该物理轮次,避免再发起一次不必要的模型请求。直接人类发起的变更不会结束轮次:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 +自主目标回合成功报告完成或阻塞后,其工具结果会附带一条收尾指令,模型仍会在轮次经由常规无工具调用停止路径结束前向用户发言;原先在结果处终结轮次的做法已被[Goal Round 收尾决策](../bug-fix/2026-08-02-goal-round-wrapup-message.md)取代。直接人类发起的变更不会收到指令:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 ### 执行权限 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 594934ec07..d07d50641b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 6658bcc960ec691f3646f4ff08d8a065d3a8d70a -2026-07-27-web-subagent-conversations.zh.md: 5e89c9a7a420687c53be961245de7adb99bf3c44 +2026-07-27-web-subagent-conversations.md: f4d2035dfc7224cd7a11575449ff79cdae3fce48 +2026-07-27-web-subagent-conversations.zh.md: 79ce2711af32fb67685f29d48fc53d29376907f4 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 6658bcc960..f4d2035dfc 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -37,7 +37,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The header action is absent only after a complete empty direct-catalog response. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. @@ -104,8 +104,8 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger, usage and timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 5e89c9a7a4..79ce2711af 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -37,7 +37,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品契约 -只有在完整的直接目录响应为空后,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 @@ -104,8 +104,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、token 总量、精确到秒的运行中耗时与冻结后 inactive 耗时、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个具有持久化用量的 inactive 可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代、用量与计时行,以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、token 总量、精确到秒的运行中耗时与冻结后 inactive 耗时、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个具有持久化用量的 inactive 可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定用量与计时行以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml index 53d40cf299..54966549ba 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-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 .agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md -2026-07-30-continuable-subagent-report-tool.md: 24922cfe88084bb0f9fea8c9363980a224b875da -2026-07-30-continuable-subagent-report-tool.zh.md: bb0b1847f157dba6116526851194cf1228e52e49 +2026-07-30-continuable-subagent-report-tool.md: 8324f1fa08f7dace6153712575e7e70a07ee9344 +2026-07-30-continuable-subagent-report-tool.zh.md: e7599c1d85328e83c7773718101b59e763a1e37f diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md index 24922cfe88..8324f1fa08 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md @@ -54,7 +54,7 @@ The first version provides no durable mailbox, idempotency key, delivery receipt The subagent seam adds `registerContinuableSetup(contribution): () => void`, backed by `SubagentActivationSetupRegistry`. Each synchronous contribution receives the unpublished child context and returns the disposer for its installation. The continuation manager first applies base child composition, then current contributions in registration order through the same setup closure used for fresh creation and cold resume. -The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. A throwing or concurrently revoked contribution rejects before Activation publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. +The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. Applying a batch returns the Agent setup commit that revalidates provisioning after every setup await and immediately before Agent publication. A throwing or concurrently revoked contribution therefore rejects before either Agent or Session publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. This seam keeps the continuation manager unaware of tool names. The report package installs only `report`; `@deepseek-ai/dsh-tool-subagent-control` independently installs parent-side `send_message` and `list_agents`. A deployment can install either direction, both, or neither. Providers remain data-only, durable descriptors do not snapshot report availability or delivery mode, and cold resume uses the deployment's current contributions and policy. @@ -94,6 +94,10 @@ Mutating or cold-resuming an absent parent requires a new durable addressing, au A result-bearing wrapper makes one report or one turn appear terminal and recreates the lifetime mismatch that continuable Activations removed. Explicit repeatable sends need no intermediate execution object. +### Validate setup after Agent creation + +A post-creation revocation check can reject the Activation only after the Agent and Session have been published. Disposing the returned handle removes the live objects but cannot delete persistence through the current seam, leaving a resumable child that the continuation manager said was never established. Returning an `AgentSetupCommit` instead lets the Agent factory perform the same mutable-state check synchronously at its publication boundary. + ## Consequences - A continuable in-process child exposes exactly one scope-local `report` schema only while the report package's contribution is installed; unrelated Agents never expose it. @@ -112,5 +116,3 @@ The acceptance boundary is weaker than durable end-to-end delivery. A crash can Wakeup mode can amplify model work when nested children report frequently. Deployment ownership and a quiet default limit but do not remove that risk. Registry presence is the parent liveness signal. A host-owned parent whose `AgentHandle.dispose()` has started but has not yet unwound its scope can still accept and append a report that it will not act on in this process. Closing that gap requires an Agent-level disposal-start signal rather than subagent-layer inference. - -The final setup-revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, after lower-level Agent and Session publication. Revocation in this window rolls back the handle and prevents the subagent Activation start edge but may leave a persisted Session. Moving the cutoff before lower-level publication requires a future Agent-creation setup transaction seam. diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md index bb0b1847f1..e7599c1d85 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md @@ -54,7 +54,7 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以 subagent seam 新增 `registerContinuableSetup(contribution): () => void`,由 `SubagentActivationSetupRegistry` 支撑。每个同步贡献都会接收尚未发布的 child 上下文,并返回其安装的 disposer。继续执行管理器首先应用基础 child 组合,然后通过同一个用于首次创建与冷恢复的设置闭包,按注册顺序应用当前贡献。 -注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。某项贡献抛出异常或被并发撤销时,会在 Activation 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 +注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。应用一个批次会返回 Agent setup 提交对象,用于在所有 setup 的 await 均结算后、紧邻 Agent 发布前重新校验配置状态。因此,某项贡献抛出异常或被并发撤销时,会在 Agent 与 Session 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 该 seam 使继续执行管理器无需知道工具名。report 包只安装 `report`;`@deepseek-ai/dsh-tool-subagent-control` 则独立安装 parent 侧的 `send_message` 和 `list_agents`。部署时可安装任一方向、同时安装两者或两者均不安装。提供方仍只负责数据,持久化描述符不会对 report 可用性或投递模式建立快照,冷恢复则使用部署当前的贡献与策略。 @@ -94,6 +94,10 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, 承载结果的包装层会让一次报告或一个轮次看似具有终止性,并重新引入可继续 Activation 已经移除的生命周期不匹配。显式、可重复的发送无需中间执行对象。 +### 在 Agent 创建后校验 setup + +创建完成后的撤销检查只能在 Agent 与 Session 均已发布后拒绝 Activation。对返回的 handle 执行 dispose 会移除实时对象,但当前 seam 无法删除持久化内容,因此会留下一个仍可恢复的 child,而继续执行管理器却判定它从未建立。改为返回 `AgentSetupCommit`,Agent 工厂便可在自身的发布边界同步执行同一项可变状态检查。 + ## 影响 - 只有安装 report 包贡献时,可继续进程内 child 才会恰好暴露一个作用域局部 `report` schema;无关 Agent 永远不会暴露该 schema。 @@ -112,5 +116,3 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, wakeup 模式可能在嵌套 child 频繁报告时放大模型工作量。由部署所有者控制并默认静默,可以限制该风险,但无法完全消除。 注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未展开其作用域,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。 - -最终 setup 撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时底层 Agent 和 Session 已经发布。在该窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。若要把截止点移到底层发布之前,需要未来提供 Agent 创建 setup 事务 seam。 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml new file mode 100644 index 0000000000..f4f0ef3891 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +2026-08-02-web-thinking-tail-scroll.md: c45840731153627b4ce460ee140257ba33d2c007 +2026-08-02-web-thinking-tail-scroll.zh.md: b8d0444d62294e123bec1d26cb4c07538bbf966f diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md new file mode 100644 index 0000000000..c458407311 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -0,0 +1,31 @@ +# Agent Note: Web thinking tail scroll — collapsed reasoning follows live output + +Status: implemented + +English | [中文](2026-08-02-web-thinking-tail-scroll.zh.md) + +## Problem + +The Web Think row rendered the first reasoning line as its collapsed summary for both settled and streaming blocks. Once that first line existed, every later reasoning delta changed hidden body text only. A fast model therefore looked stationary while it was thinking, and the user had to expand the full chain of thought to verify that output was still moving. The product backlog already called for “thinking: scrolling chain-of-thought updates, expandable”; the current row satisfied only the second half. + +## Decision + +Only a collapsed Think row whose reasoning block is the active streaming tail follows live output. Its summary is the latest non-blank line instead of the settled first line, and the existing single-line summary element becomes a programmatic horizontal scrollport pinned to `scrollWidth - clientWidth` after each text update. Direct `scrollLeft` assignment deliberately follows real deltas without inventing an independent marquee speed: fast tokens move fast, a paused model stops, and short text stays still because the scroll range is zero. + +The behavior is owned by the existing presentation components. `AssistantMarkdown` chooses the latest line only while the Think row is running; `ToolRow` already owns collapsed/open state and therefore owns whether its summary should follow the inline end. No session, wire, durable event, or model-visible contract changes. Expanding removes the collapsed summary and renders the complete reasoning body in ordinary page flow. When the row settles, it restores the stable first line and resets the summary to the left edge. Other tool summaries and settled Think rows retain their existing ellipsis behavior. + +## Alternatives considered + +**Animate a CSS marquee independent of streaming.** Rejected: it would keep moving through provider stalls and make a slow model look fast, which breaks the throughput signal the interaction exists to expose. + +**Always show a fixed suffix of the complete reasoning string.** Rejected: character slicing can cut a word or grapheme, discards the current line’s beginning before overflow actually requires it, and jumps rather than moving with each delta. + +**Auto-scroll the expanded reasoning body or the conversation page.** Rejected: expanded content is a reading surface. Forcing it to follow would fight a user who scrolls back; the follower belongs only to the collapsed one-line summary. + +## Consequences + +The collapsed row now communicates provider cadence through content motion as well as the existing sweep, while the settled transcript remains byte-for-byte stable. The scroll update runs only on React renders the streaming accumulator already causes; it adds no timer, animation loop, subscription, durable state, or transport traffic. A long current reasoning line retains its full DOM text and programmatically clips the already-overflowing prefix, so expansion still reveals the complete block and assistive technology reads the same current summary text. + +## Testing + +`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md new file mode 100644 index 0000000000..b8d0444d62 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -0,0 +1,31 @@ +# Agent Note:Web 思考尾部滚动 —— 折叠态 reasoning 跟随实时输出 + +Status: implemented + +[English](2026-08-02-web-thinking-tail-scroll.md) | 中文 + +## 问题 + +Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠摘要。首行一旦出现,之后每个 reasoning delta 只会改变隐藏的正文。于是快速模型在思考时看起来静止,用户必须展开完整思维链才能确认输出仍在推进。产品事项表已经要求“thinking:滚动展示思维链更新、可展开”;当前行只满足了后半项。 + +## 决策 + +只有 reasoning block 是当前流式尾部、且仍处于折叠态的 Think 行会跟随实时输出。其摘要使用最新的非空行,而不是结算后的首行;已有单行摘要元素成为程序化横向滚动区,每次文本更新后钉到 `scrollWidth - clientWidth`。这里刻意直接赋值 `scrollLeft`,通过真实 delta 推进而不虚构独立的跑马灯速度:token 快则移动快,模型停顿则停止,短文本因滚动范围为零而保持静止。 + +该行为由已有呈现组件拥有。`AssistantMarkdown` 只在 Think 行运行时选择最新行;`ToolRow` 已经拥有折叠/展开状态,因此由它决定摘要是否追随行内末端。不改变 session、wire、持久事件或模型可见契约。展开会移除折叠摘要,并让完整 reasoning 正文进入普通页面流。该行结算后恢复稳定首行,同时把摘要重置到左端。其他工具摘要与已结算 Think 行保留已有省略号行为。 + +## 曾考虑的替代方案 + +**播放与流式输出无关的 CSS 跑马灯。** 否决:它会在 provider 停顿时继续移动,让慢模型显得很快,破坏该交互本应暴露的吞吐信号。 + +**始终显示完整 reasoning 字符串的固定后缀。** 否决:按字符切片可能截断单词或字素,在内容真正溢出前就丢掉当前行的开头,而且只会跳变,无法随每个 delta 移动。 + +**自动滚动展开的 reasoning 正文或会话页面。** 否决:展开内容是阅读界面,强制跟随会与向上回看的用户争夺滚动;跟随器只属于折叠的单行摘要。 + +## 后果 + +折叠行现在会同时通过内容移动和已有扫光传达 provider 节奏,而结算后的 transcript 保持逐字节稳定。滚动更新只发生在流式累加器本就会触发的 React 渲染中;不会增加计时器、动画循环、订阅、持久状态或传输流量。较长的当前 reasoning 行仍会把完整文本留在 DOM 中,只以编程方式裁掉已经溢出的前缀,因此展开仍能显示完整 block,辅助技术读到的也仍是同一份当前摘要文本。 + +## 测试 + +`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要契约仍然稳定。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index ad2e2c40bd..f0587a9b39 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 5029d8335f699e99e67c6027b7d1666880db4724 -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 0785730c1934a192380af41f3ad88f95a2747cf7 +2026-07-27-intent-named-subagent-continuation-operations.md: e74d62b7582e92f8e5ce68327a677259c8453d24 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: dae4dd37fa9950f0b8d1ba6ec5c46b99977a7201 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 5029d8335f..e74d62b758 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) -The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, retains its bare `Agent` parameter as exact live-direct-parent authority, and replaces provider `resume` dispatch with `prepareContinuable`. +The current activation-based realization is owned by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md). It retains the `followup` operation this record names, returns the accepted `MessageId`, uses the bare `Agent` parameter as exact live-direct-parent authority, and limits provider participation in continuable children to `prepareContinuable`. ## Problem @@ -14,25 +14,25 @@ The durability boundary also exposed both `SessionStore.flush()` and `flushRequi ## Decision -`SubagentService` exposes three execution intents: `start(name, request)` for an ordinary holder-owned run, `startContinuable(spec)` for a durable Task-backed child, and `followup(parent, childId, content, { source, signal })` for later content. The last verb matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-activation capability. The model-facing tool keeps its stable `send_message` name and delegates routing to `followup()`. +`SubagentService` separates four execution intents: `start(name, request)` returns an ordinary holder-owned one-shot run; `startContinuable(spec)` establishes a durable child and returns its id plus the accepted initial `MessageId`; `followup(parent, childId, content, { source, signal })` sends later parent content; and `reportFrom(child, content, { delivery, signal })` sends selected child content to its direct parent. `followup` matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-run capability. The model-facing tools keep their stable `send_message` and `report` names and delegate routing to the corresponding intent methods. -Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. +Caller and provider requests are distinct. `SubagentStartRequest` contains caller-supplied one-shot data; `ResolvedSubagentStartRequest` adds the service-resolved descriptor before `SubagentProvider.start()`. For continuable creation, the manager passes a `ContinuableCreateRequest` to optional `SubagentProvider.prepareContinuable()` and receives detached creation data only. `SubagentService.resume()` and provider resume dispatch are absent: the continuation manager loads the descriptor, authorizes the parent, and owns Agent materialization, prompt delivery, cold resume, and teardown. -`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. +`SessionStore.flush(session)` is the single durability barrier and returns `Promise`. It resolves `true` after at least one scoped listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Participation cannot identify whether a selected persistence backend stored the state. Ordinary checkpoints may ignore the boolean; the continuation manager also treats its final flush as a best-effort barrier, deliberately ignores participation, logs rejection, and still disposes the child and releases ownership. ## Alternatives considered -**Keep public provider resume dispatch.** No production caller outside the continuation manager owns the descriptor lookup, direct-parent authorization, Task cancellation, and activation association needed to call it safely. A public method would expose resolved implementation data without a valid independent intent. +**Keep public provider resume dispatch.** No production caller outside the continuation manager owns descriptor lookup, direct-parent authorization, Agent materialization, Activation ownership, and child-first teardown. A public method would expose resolved implementation data without a valid independent intent; providers instead contribute detached first-creation data through `prepareContinuable` and never participate in cold resume. **Keep `sendMessage` on the service.** The model tool sends a message, but the service operation represents a follow-up that may steer or cold-resume. `followup` aligns with the structural `Agent` interface and does not promise a particular route. **Keep `flushRequired()`.** A second method hides only an empty-listener check. Returning participation from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable. -**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned run or immediate child/Task identities. Separate intent methods preserve the ownership and timing distinction without a return union. +**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned one-shot run or immediate durable child and message identities. Separate intent methods preserve the ownership and timing distinction without a return union. ## Consequences -- The Cordis service catalog contains only caller operations; provider reconstruction remains extensible through `SubagentProvider.resume?()` without exposing its resolved request as a service method. +- The Cordis service catalog contains only caller operations; a provider can opt into continuable first creation through `SubagentProvider.prepareContinuable?()` without receiving Agent lifecycle authority or a public resume operation. - Follow-up source and cancellation travel in one options object, matching the intent-helper shape on `Agent` while retaining the existing live-delivery and cold-resume semantics. -- Session durability has one barrier operation. Callers that require a backend must inspect its participation result rather than selecting a second dispatch method. -- The `send_message` schema, route results, Task ownership, durable event vocabulary, and model-visible transcript remain unchanged. +- Session durability has one barrier operation. Its participation result remains observable, but no continuable-child path treats arbitrary listener participation as proof that a persistence backend stored the state. +- The `send_message` and `report` schemas, accepted message identities, `AgentHandle` ownership, durable event vocabulary, and model-visible transcript follow the activation-based realization linked above. diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 0785730c19..dae4dd37fa 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 -本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,保留裸 `Agent` 参数作为准确的实时直属父级权限,并以 `prepareContinuable` 替换提供方 `resume` 派发。 +当前基于 Activation 的实现由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)负责。它保留本记录命名的 `followup` 操作,返回已接受的 `MessageId`,使用裸 `Agent` 参数作为确切的在线直属父级权限,并将提供方对可继续 child 的参与限制为 `prepareContinuable`。 ## 问题 @@ -14,25 +14,25 @@ Status: implemented ## 决策 -`SubagentService` 公开三种执行意图:`start(name, request)` 用于普通的、由持有方负责的 run;`startContinuable(spec)` 用于具备持久性且由 Task 支撑的 child;`followup(parent, childId, content, { source, signal })` 用于投递后续内容。最后一个动词与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的激活提供 steering(中途引导)。面向模型的工具保留稳定的 `send_message` 名称,并将路由委托给 `followup()`。 +`SubagentService` 分离四种执行意图:`start(name, request)` 返回普通的、由持有方负责的 one-shot run;`startContinuable(spec)` 建立持久化 child,并返回其 id 与已接受的初始 `MessageId`;`followup(parent, childId, content, { source, signal })` 发送后续 parent 内容;`reportFrom(child, content, { delivery, signal })` 将选定的 child 内容发送给其直接 parent。`followup` 与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的 run 提供 steering。面向模型的工具保留稳定的 `send_message` 与 `report` 名称,并将路由委托给对应的意图方法。 -调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 +调用方请求与提供方请求相互分离。`SubagentStartRequest` 包含调用方提供的 one-shot 数据;`ResolvedSubagentStartRequest` 会在调用 `SubagentProvider.start()` 前加入由服务解析的描述符。创建可继续 child 时,管理器将 `ContinuableCreateRequest` 传给可选的 `SubagentProvider.prepareContinuable()`,且只接收分离的创建数据。`SubagentService.resume()` 与提供方恢复分发均不存在:继续执行管理器加载描述符、对 parent 进行鉴权,并负责 Agent 实体化、提示词投递、冷恢复与 teardown。 -`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。 +`SessionStore.flush(session)` 是唯一的持久性屏障,并返回 `Promise`。至少一个作用域内监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。参与结果无法表明所选的持久化后端是否已经存储状态。普通检查点可以忽略该布尔值;继续执行管理器同样将最终 flush 视为 best-effort 屏障,有意忽略参与结果,记录拒绝日志,并仍会对 child 执行 dispose(资源释放)并释放所有权。 ## 已考虑的替代方案 -**保留公开的提供方恢复分发。** 继续执行管理器之外没有任何生产调用方负责安全调用所需的描述符查找、直接 parent 鉴权、Task 取消与激活关联。公开方法会暴露已解析的实现数据,但并不存在与之对应的合理独立调用意图。 +**保留公开的提供方恢复分发。** 继续执行管理器之外,没有任何生产调用方同时负责安全调用所需的描述符查找、直接 parent 鉴权、Agent 实体化、Activation 所有权与 child-first teardown。公开方法会暴露已解析的实现数据,却没有合理的独立调用意图;提供方改为通过 `prepareContinuable` 贡献分离的首次创建数据,且从不参与冷恢复。 **在服务上保留 `sendMessage`。** 面向模型的工具发送消息,但服务操作表达的是后续操作,既可能对运行中的激活执行 steering,也可能从持久化存储恢复。`followup` 与结构化 `Agent` 接口保持一致,也不承诺特定路由。 **保留 `flushRequired()`。** 第二个方法只封装了空监听器检查。由现有屏障返回是否有监听器参与,可以让分发只保留一套实现,并让每个调用方自行判定缺少监听器是否可接受。 -**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 run 就绪后返回,要么立即返回 child 和 Task 标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 +**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 one-shot run 就绪后返回,要么立即返回持久化 child 与消息标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 ## 影响 -- Cordis 服务目录只包含调用方操作;提供方的重建能力仍可通过 `SubagentProvider.resume?()` 扩展,同时不会将已解析的请求暴露为服务方法。 +- Cordis 服务目录只包含调用方操作;提供方可以通过 `SubagentProvider.prepareContinuable?()` 选择参与可继续 child 的首次创建,但不会获得 Agent 生命周期权限或公开恢复操作。 - 后续操作的来源与取消信号通过同一个选项对象传递,与 `Agent` 上按意图命名的辅助方法形态一致,同时保留在线投递与从持久化存储恢复的语义。 -- 会话持久性只保留一个屏障操作。需要后端参与的调用方必须检查参与结果,而不是选择第二种分发方法。 -- `send_message` schema、路由结果、Task 所有权、持久化事件词汇与模型可见的 transcript(文本记录)保持不变。 +- 会话持久性只有一个屏障操作。参与结果仍可观测,但任何可继续 child 路径都不会将任意监听器参与视为持久化后端已存储状态的证明。 +- `send_message` 与 `report` schema、已接受的消息标识、`AgentHandle` 所有权、持久化事件词汇与模型可见的 transcript(文本记录)遵循上文链接的基于 Activation 的实现。 diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index 2a5205cd0d..6b31c8f424 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -28,6 +28,9 @@ - id: tool-subagent-control disabled: true +- id: tool-subagent-list-agents + disabled: true + - id: tool-subagent disabled: true diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts index 5f3334c0e7..58f2a34858 100644 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ b/apps/web/tests/core-web-profile.snapshot.ts @@ -72,7 +72,6 @@ describe('core Web profile', () => { "tools": [ "bash", "str_replace_editor", - "list_agents", ], } `) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index e285b0f0ac..8378bf6f5b 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -158,8 +158,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) - await input.press('Enter') - const sessionId = await settled + const observeTurn = async () => { + const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } + if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 }) + try { + await input.press('Enter') + if (MODE !== 'record') { + const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]') + await expect.poll(async () => await liveTail.evaluate(element => ( + element.scrollWidth > element.clientWidth + && element.scrollLeft >= element.scrollWidth - element.clientWidth - 1 + )), { timeout: 10_000, interval: 10 }).toBe(true) + } + return await settled + } finally { + if (MODE !== 'record') await page.setViewportSize(originalViewport) + } + } + const sessionId = await observeTurn() if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } diff --git a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md index 7d7595c7a4..9f7c0f23f7 100644 --- a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md @@ -14,5 +14,5 @@ - button "Branch into a new conversation": - img - status: - - strong: 此子代理暂时只读 - - text: 父会话当前不在线,重新打开父会话后即可继续发送消息。 + - strong: This subagent is read-only for now + - text: The parent session is offline; reopen it to continue sending messages. diff --git a/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md b/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md new file mode 100644 index 0000000000..bd2a1b7918 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md @@ -0,0 +1,3 @@ +- tree "Subagent sessions": + - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents… + - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents… diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index b1e23e4020..6b695ff029 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ -- tree "子代理会话": - - treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 7.9K tok · 2秒" [expanded] [level=1]: - - button "收起 event-sourcing researcher 的下级子代理": +- tree "Subagent sessions": + - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]: + - button "Collapse event-sourcing researcher descendants": - img - - text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 7.9K tok · 2秒 + - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}} - group: - - treeitem "example editor 可继续 · 当前未运行 0 tok · 0秒" [level=2] - - treeitem "event-sourcing reviewer 一次性 · 当前未运行 0 tok · 0秒" [level=1] + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2] + - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1] diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 6513bb60e3..14b397d9b4 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,8 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] - - button "1 个子代理": - - text: 1 个子代理 + - button "1 subagent": + - text: 1 subagent - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 2dff253b66..b719037867 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -15,11 +15,12 @@ import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) +const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url)) @@ -77,7 +78,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = paceMs: 25, }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) page.on('request', (request) => { const path = new URL(request.url()).pathname if (path.startsWith('/api/')) apiCalls.push(path) @@ -219,13 +220,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const catalogButton = page.getByRole('button', { name: /个子代理/ }) + const catalogButton = page.getByRole('button', { name: /subagents/ }) await catalogButton.waitFor({ timeout: 15_000 }) await catalogButton.click() - const catalogTree = page.getByRole('tree', { name: '子代理会话' }) + const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 }) await catalogTree.press('Escape') - await page.getByRole('button', { name: '3 个子代理' }).waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) }, 120_000) @@ -241,13 +242,66 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed') }) + it('keeps known descendants reachable across a stale empty catalog response', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog')) + const pattern = '**/api/subagent.list' + let firstClaimed = false + let emptyDelivered = false + let trailingRequested = false + let releaseCatalog = (): void => {} + const catalogHeld = new Promise((resolve) => { releaseCatalog = resolve }) + await page.route(pattern, async (route) => { + if (firstClaimed) { + const response = await route.fetch() + trailingRequested = true + await catalogHeld + await route.fulfill({ response }) + return + } + firstClaimed = true + const response = await route.fetch() + const body = await response.json() as { + result: { ok: true; value: { entries: unknown[] } } | { ok: false } + } + if (body.result.ok) body.result.value.entries = [] + await route.fulfill({ response, json: body }) + emptyDelivered = true + }) + + const warningStart = tripwire.warnings.length + try { + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true) + await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + + await page.getByRole('button', { name: '3 subagents' }).click() + await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true) + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor() + expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2) + await compareOrRefreshGolden( + STALE_CATALOG_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), + MODE, + ) + releaseCatalog() + await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 }) + await tree.press('Escape') + } finally { + releaseCatalog() + await page.unroute(pattern) + } + }) + it('expands a persisted grandchild progressively without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() expect(await page.getByRole('button', { - name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`, + name: `Expand ${ONE_SHOT_LABEL} descendants`, }).count()).toBe(0) - await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click() + await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) const childLabel = await childRow.getAttribute('aria-label') await page.waitForTimeout(1_100) @@ -257,16 +311,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() const snapshot = await captureStableAria( page, - '[role="tree"][aria-label="子代理会话"]', + '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE) - await page.getByRole('tree', { name: '子代理会话' }).press('Escape') + await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape') }) it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll( () => page.getByText(INITIAL_PROMPT, { exact: true }).count(), @@ -308,18 +362,18 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe('running') const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) await hierarchy.getByRole('button').first().click() - const runningTrigger = page.getByRole('button', { name: '3 个子代理,正在运行' }) + const runningTrigger = page.getByRole('button', { name: '3 subagents running' }) await runningTrigger.waitFor({ timeout: 10_000 }) expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) await runningTrigger.click() await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*正在运行`), + name: new RegExp(`${LABEL}.*running`), }).waitFor({ timeout: 10_000 }) await ended await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*当前未运行`), + name: new RegExp(`${LABEL}.*not running`), }).waitFor({ timeout: 10_000 }) - expect(await page.getByRole('button', { name: '3 个子代理' }) + expect(await page.getByRole('button', { name: '3 subagents' }) .locator('[data-state="ongoing"]').count()).toBe(0) await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1) @@ -337,9 +391,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens an unavailable persisted grandchild after recording the available child', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) - await page.getByRole('button', { name: '1 个子代理' }).click() + await page.getByRole('button', { name: '1 subagent' }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() - await page.getByText('父会话当前不在线,重新打开父会话后即可继续发送消息。').waitFor() + await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) @@ -358,9 +412,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem') .last() await parentSession.click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click() - await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor() + await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() }) @@ -369,7 +423,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('tree', { name: 'Sessions' }) .getByRole('treeitem', { name: /Ask a research subagent to/ }) .click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() const forkResponse = page.waitForResponse(response => @@ -395,7 +449,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup')) const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.locator('textarea:enabled').first().waitFor() expect(scaffold.ctx.agents.get(childId)).toBeUndefined() @@ -412,7 +466,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined() await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() const input = page.locator('textarea:enabled').first() await input.waitFor() diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index f0e2a9892c..bded8b85c8 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 4bb0cb1f29bb48adf89c97af7c85c90219d0558a -architecture.zh.md: 4c6ff721d894aaee4cc14ff321967580997aba56 +architecture.md: b11ab9bc3060aea668d142139e0a25f491a777d1 +architecture.zh.md: 5eb1cab7e453dc0423cbb42348e918de798f1f9b diff --git a/docs/architecture.md b/docs/architecture.md index 4bb0cb1f29..b11ab9bc30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Creation without an id mints `-session-`; `sessionId` resumes o ```text choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup + -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -141,7 +141,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Scope -Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind 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)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication and may return a synchronous commit that the factory invokes immediately before registry entry, after every setup await. 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)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 4c6ff721d8..5eb1cab7e4 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -76,7 +76,7 @@ waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委 ```text choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup + -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -141,7 +141,7 @@ idle inject: ### 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)` 在发布前完成组合,并可返回一个同步提交操作;所有 setup 的 await 均完成后,工厂会在进入注册表前立即调用该操作。类型化解析器从合并后的 `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))。 ## 状态 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2a40c7d800..cc85531d4d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -765,7 +765,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:617`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:707`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1772,7 +1772,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:26`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-lsp` @@ -2039,7 +2039,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:589`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:592`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ae426792e1..cf18c6f73c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -2428,7 +2428,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:714`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 156324472b..a955adc963 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 docs/core-data-structures/tools.md -tools.md: 98b642b846b23e2b29e6c6d800fe4106235eda85 -tools.zh.md: 1ef90c1e76ace7485ed6267de5ee82cbb4de6aa6 +tools.md: acaf5d32dd5481aec495ac49f727c9b64f25211e +tools.zh.md: 5c39226fdf5d1406dab7383d40227c26ff1e447c diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 98b642b846..acaf5d32dd 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -200,20 +200,23 @@ interface ToolExecutionInput { } ``` -A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call. +A tool body receives the runtime extension. `deferContext()` attaches context to the execution's own result — the composite-tool nested-dispatch channel, also usable by a leaf tool minting a plugin-sourced instruction — without injecting inside the still-open outer call. ```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`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `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. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — 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: UserMessage): void /** diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 1ef90c1e76..5c39226fdf 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -200,20 +200,23 @@ interface ToolExecutionInput { } ``` -工具函数体接收运行时扩展。`deferContext()` 是组合工具的通道:它记录嵌套分派产生的上下文,而不会在外层调用尚未结束时注入这些上下文。 +工具函数体接收运行时扩展。`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`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `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. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — 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: UserMessage): void /** diff --git a/docs/module-graph.md b/docs/module-graph.md index 2af5dc62ea..6687a583c1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -832,6 +832,7 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation pkg_client_ui_subagent --> pkg_client_ui_primitives @@ -1217,7 +1218,7 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0aa81a7939..b3ac5aae70 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -531,7 +531,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/ 'subagent/descriptor': SubagentDescriptorData ``` -Source: [`packages/subagent/subagent/src/descriptor.ts:32`](../packages/subagent/subagent/src/descriptor.ts) +Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) ### `todo/*` diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json new file mode 100644 index 0000000000..8b6865a6ed --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json @@ -0,0 +1,13 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "promptAndWaitForAgentMessage", + "text": "Create a durable goal for the wrap-up snapshot, then report readiness.", + "waitForText": "GOAL READY" + }, + { "op": "waitForTurnStart", "minimumTurn": 2 }, + { "op": "waitForTurnEnd" } + ] +} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json new file mode 100644 index 0000000000..d36b73f313 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json @@ -0,0 +1,42 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_create", "name": "create_goal", "argumentsDelta": "{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_create", "name": "create_goal", "arguments": "{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL READY" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL READY" } }, + { "type": "usage", "usage": { "inputTokens": 28, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_complete", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_complete", "name": "update_goal", "arguments": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" } }, + { "type": "usage", "usage": { "inputTokens": 40, "outputTokens": 9 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user." } }, + { "type": "usage", "usage": { "inputTokens": 52, "outputTokens": 14 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl new file mode 100644 index 0000000000..4355ccb070 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl @@ -0,0 +1,50 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal for","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"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 ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}} +{"type":"assistant/chunk","seq":9,"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 ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}} +{"type":"tool/result","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"user/message","seq":15,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":28,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":28,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":25,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":26,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1}}}} +{"type":"user/message","seq":27,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":28,"time":0,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":0,"data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} +{"type":"tool/result","seq":36,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"user/message","seq":37,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"maxGoalRounds\":2},\"roundsStarted\":1,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":40,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":46,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":48,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl new file mode 100644 index 0000000000..94002ff85d --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"goal-wrapup-placeholder","createdAt":0,"cwd":"{{cwd}}"} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl new file mode 100644 index 0000000000..e5c0dbb921 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl @@ -0,0 +1,5 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index b6a44cb66c..a7757eacf7 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -21,6 +21,7 @@ const fixtureFile = join(scenarioDir, 'session.jsonl') const overrideFile = join(scenarioDir, 'replay.override.json') const stdoutExpected = join(scenarioDir, 'stdout.expected.jsonl') const sessionExpected = join(scenarioDir, 'session.expected.jsonl') +const wrapupDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-wrapup') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const agent: AgentUnderTest = { @@ -112,4 +113,62 @@ describe('same-session goal snapshot through the ACP automation driver', () => { expect(stdout).toBe(await readFile(stdoutExpected, 'utf8')) expect(session).toBe(await readFile(sessionExpected, 'utf8')) }) + + it('injects the wrap-up instruction after an autonomous completion and delivers a closing message', async () => { + const input = JSON.parse(await readFile(join(wrapupDir, 'input.json'), 'utf8')) as InputScript + const result = await runScenario(input, { + agent, + mode: 'replay', + fixtureFile: join(wrapupDir, 'session.jsonl'), + overrideFile: join(wrapupDir, 'replay.override.json'), + configPath: agent.configPath, + }) + + expect(result.stderr).toBe('') + expect(result.sessionLogs).toHaveLength(1) + const log = result.sessionLogs[0] + if (log === undefined) throw new Error('goal wrap-up snapshot did not persist its session') + const records = parseJsonl(log.content) + const events = records.slice(1) as unknown as SessionEvent[] + const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name) + expect(calls).toEqual(['create_goal', 'update_goal']) + expect(foldGoal(events)).toMatchObject({ + goal: { + objective: 'Finish the ACP goal wrap-up snapshot proof', + phase: 'complete', + revision: 2, + }, + roundsStarted: 1, + }) + // The wrap-up instruction is one plugin-sourced context injected after the + // terminal tool result, and the model still answers inside the same turn. + const wrapups = events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tool-goal') + expect(wrapups).toHaveLength(1) + const wrapupText = wrapups.map(event => event.type === 'user/message' ? event.data.content : [])[0] + expect(JSON.stringify(wrapupText)).toContain('') + const closing = events.filter(event => event.type === 'assistant/message') + .flatMap(event => event.data.message.content) + .filter(block => block.type === 'text' && block.text.startsWith('GOAL WRAP-UP')) + expect(closing).toHaveLength(1) + const roundTurnEnds = events.filter(event => event.type === 'turn/end' && event.data.turn === 2) + expect(roundTurnEnds).toEqual([expect.objectContaining({ data: { turn: 2, reason: { kind: 'completed' } } })]) + + const context: NormalizeContext = { + sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined), + cwd: result.cwd, + } + const stdout = normalizeStdout(result.rawStdout, context) + const session = normalizeGoalLog(log.content, context) + const wrapupStdoutExpected = join(wrapupDir, 'stdout.expected.jsonl') + const wrapupSessionExpected = join(wrapupDir, 'session.expected.jsonl') + if (refreshing) { + await Promise.all([ + writeFile(wrapupStdoutExpected, stdout), + writeFile(wrapupSessionExpected, session), + ]) + } + expect(stdout).toBe(await readFile(wrapupStdoutExpected, 'utf8')) + expect(session).toBe(await readFile(wrapupSessionExpected, 'utf8')) + }) }) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 0a2f7f7f68..b88322012c 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto' import { isAbsolute } from 'node:path' import { Readable, Writable } from 'node:stream' import Schema from 'schemastery' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import { AgentSideConnection, ndJsonStream, @@ -368,7 +368,14 @@ export function apply(ctx: Context, config: AcpConfig): void { if (result.status === 'rejected') failures.push(result.reason as unknown) } if (failures.length > 0) { - throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`) + // The production consumer logs this AggregateError through `String`, + // which renders only its message. Embed every per-session diagnostic, + // including nested causes and aggregate members, in that message. + const detail = failures.map(failure => errorChain(failure)).join('; ') + throw new AggregateError( + failures, + `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, + ) } })() return quiescing diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 5f6b5babd5..b4b1eaeef2 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -96,7 +96,7 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) - it('awaits every owned session disposal before reporting one failure', async () => { + it('awaits every owned session disposal and reports nested failure reasons', async () => { harness = await makeBridgeHarness() const create = harness.ctx.agents.create.bind(harness.ctx.agents) const releaseSecond = Promise.withResolvers() @@ -110,7 +110,10 @@ describe('ACP connection ownership', () => { if (created++ === 0) { handle.dispose = async () => { await originalDispose() - throw new Error('first session cleanup failed') + throw new AggregateError([ + new Error('scope cleanup failed', { cause: new Error('sqlite busy') }), + new Error('hook cleanup failed'), + ], 'first session cleanup failed') } } else { handle.dispose = async () => { @@ -131,7 +134,11 @@ describe('ACP connection ownership', () => { releaseSecond.resolve(undefined) await vi.waitFor(() => { - expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true) + expect(warnings.some(warning => + warning.includes( + 'ACP agent teardown failed for 1 session(s): ' + + 'first session cleanup failed [scope cleanup failed: sqlite busy; hook cleanup failed]', + ))).toBe(true) expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined() }) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index d87ed0a04f..b4bf12a0e3 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -59,6 +59,8 @@ interface CatalogInflight { readonly promise: Promise readonly expandableRows: Set readonly activityRows: Map + /** Removal-time invalidation replayed over the response this request predates. */ + parentAvailableOverride: false | undefined } type SessionListMutation = @@ -101,6 +103,8 @@ export class SessionManager { private readonly addresses = new Map() private readonly catalogs = new Map() private readonly catalogInflight = new Map() + /** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */ + private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() @@ -301,22 +305,26 @@ export class SessionManager { try { const { result } = await this.api.subagents.list({ parentSessionId }) if (result.ok) { + const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? result.value.parentAvailable this.catalogs.set(parentSessionId, { ...result.value, entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows), + parentAvailable, state: 'ready', error: null, }) for (const [childId, address] of this.addresses) { if (address.parentSessionId !== parentSessionId) continue - this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable) + this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable) } } else { this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: result.error, }) @@ -327,16 +335,26 @@ export class SessionManager { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: folded.ok ? null : folded.error, }) } finally { this.catalogInflight.delete(parentSessionId) + // Re-arm the trailing pull before the dirty notify: the response the + // caller observed predates the stale-marking change, so the follow-up + // refresh is the only carrier of that change. + if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId) this.notifier.markDirty() } })() - this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows }) + this.catalogInflight.set(parentSessionId, { + promise: operation, + expandableRows, + activityRows, + parentAvailableOverride: undefined, + }) return operation } @@ -673,6 +691,29 @@ export class SessionManager { this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) + // A pull already in flight was requested before this removal and can + // carry the pre-removal parentAvailable:true, which would resurrect + // the writable editor this invalidation just closed. Replay false over + // that response and queue one trailing refresh so the post-removal + // host truth converges. + const inflightCatalog = this.catalogInflight.get(frame.sessionId) + if (inflightCatalog !== undefined) { + inflightCatalog.parentAvailableOverride = false + this.catalogStale.add(frame.sessionId) + } + // The removed session can no longer be the delivery owner of its + // catalog: invalidate availability immediately. Removal schedules no + // catalog refresh, and without this an addressed child keeps a + // writable editor against a dead continuation owner until an + // unrelated refresh (or forever, for a closed menu). + const ownedCatalog = this.catalogs.get(frame.sessionId) + if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { + this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false }) + } + for (const [childId, address] of this.addresses) { + if (address.parentSessionId !== frame.sessionId) continue + this.sessions.get(childId)?.handleSubagentParentAvailable(false) + } return } case 'host/session-status': { @@ -724,11 +765,18 @@ export class SessionManager { for (const session of this.sessions.values()) void session.resync() } - /** Debounce membership refetches while one parent catalog is open. */ + /** Debounce membership refetches while one parent catalog is selected or open. */ private scheduleCatalogRefresh(parentSessionId: SessionId): void { if (this.catalogDebounce.has(parentSessionId)) return const timer = setTimeout(() => { this.catalogDebounce.delete(parentSessionId) + // The in-flight response predates the membership frame that scheduled + // this callback. Queue one post-settlement pull instead of treating an + // ordinary overlapping read as evidence that catalog membership changed. + if (this.catalogInflight.has(parentSessionId)) { + this.catalogStale.add(parentSessionId) + return + } void this.refreshSubagents(parentSessionId) }, 50) this.catalogDebounce.set(parentSessionId, timer) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 6ada34de7f..f6d7baf318 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -529,6 +529,149 @@ describe('subagent catalogs', () => { { kind: 'child', id: S2, activity: 'inactive' }, ]) }) + + it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + + const refresh = manager.refreshSubagents(root) + expect(manager.refreshSubagents(root)).toBe(refresh) + api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + first.resolve(ok({ entries: [], parentAvailable: true })) + await refresh + + expect(api.callsOf('subagent.list')).toHaveLength(1) + }) + + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { + vi.useFakeTimers() + try { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + const second = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api, root) + const refresh = manager.refreshSubagents(root) + + // A membership frame arrives while the pull is in flight; the debounced + // refresh it schedules fires 50ms later and is coalesced into the pull — + // which was requested before the new child existed. The stale mark must + // queue one trailing pull carrying the change. + manager.handleHostEnvelope({ + rpcId: 'child-added' as never, + payload: { + type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false, + }, + }) + await vi.advanceTimersByTimeAsync(50) + api.onSubagentList = () => second.promise + first.resolve(ok({ + entries: [{ + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + await refresh + // The trailing pull is already in flight (kicked synchronously in finally). + second.resolve(ok({ + entries: [ + { + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }, + { + kind: 'child', id: S2, mode: 'continuable', label: 'new child', + activity: 'inactive', hasChildren: false, + }, + ] as never[], + parentAvailable: true, + })) + await second.promise + + expect(api.callsOf('subagent.list')).toHaveLength(2) + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, label: 'older' }, + { kind: 'child', id: S2, label: 'new child' }, + ]) + } finally { + vi.useRealTimers() + } + }) + + it('keeps removal invalidation across a stale success and failed trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const child = () => ({ + kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker', + activity: 'inactive' as const, hasChildren: false, + }) + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + const refresh = manager.refreshSubagents(root) + first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await refresh + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + + // The removal lands while a second pull is in flight: the invalidation + // must survive the pre-removal ok response, so one trailing pull runs. + const mid = deferred>>() + api.onSubagentList = () => mid.promise + const midRefresh = manager.refreshSubagents(root) + manager.handleHostEnvelope({ + rpcId: 'parent-removed-mid-pull' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + const trailing = deferred>>() + api.onSubagentList = () => trailing.promise + mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await midRefresh + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + + trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} })) + await vi.waitFor(() => { + expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ + state: 'error', + parentAvailable: false, + }) + }) + + const rootCalls = api.callsOf('subagent.list') + .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root) + expect(rootCalls).toHaveLength(3) + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) + + it('invalidates catalog availability when the owning parent is removed', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + api.onSubagentList = () => Promise.resolve(ok({ + entries: [{ + kind: 'child', id: S2, mode: 'continuable', label: 'worker', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + const manager = new SessionManager(api) + await manager.refreshSubagents(root) + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) + + manager.handleHostEnvelope({ + rpcId: 'parent-removed' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) }) describe('remaining branches', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a78b80035b..d131e31202 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: ea48725b02ad0c440984af4aadfec17d0e63791d -README.zh.md: 654901caca68762e313dda456c1e0df23faf734a +README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439 +README.zh.md: 268e05a806db1468ba689608c178646af132fe25 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ea48725b02..e610b990dd 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,6 +16,8 @@ The session header declares and renders the session-scoped `'conversation.sessio Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). +A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). + Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 654901caca..268e05a806 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,6 +14,8 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 +Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 + 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 387a7fd82a..f5cbadef8a 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -39,6 +39,13 @@ function firstLine(text: string): string { return nl === -1 ? text : text.slice(0, nl) } +/** Latest non-blank reasoning line while the block is still streaming. */ +function latestLine(text: string): string { + const visible = text.trimEnd() + const nl = visible.lastIndexOf('\n') + return nl === -1 ? visible : visible.slice(nl + 1) +} + /** Joined text blocks for the copy action (reasoning / tool heads stay out). */ function copyText(blocks: readonly AssistantBlock[]): string { const parts: string[] = [] @@ -61,7 +68,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass variant="think" icon={} title="Think" - summary={firstLine(text)} + summary={running ? latestLine(text) : firstLine(text)} body={text} state={running ? 'running' : 'ok'} /> diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 81e41b066f..14bfe0e76c 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -84,6 +84,11 @@ color: var(--dsw-alias-label-tertiary); } +/* Live reasoning follows its one-line summary to the inline end. */ +.summary[data-follow-end] { + text-overflow: clip; +} + /* File-tool path: same geometry as .summary; hover underline + pointer. */ .fileLink { flex: 1 1 auto; diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 46cdbda191..fa4be6179a 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -5,7 +5,8 @@ // Enter / Space, icon→chevron hover preview). The collapsed row is always // one line; every row with body, output, or a card material (terminal, diff, // read, search, web) is expandable; the summary stays inline while open, -// except Think, whose body opens with the same first line and would repeat it. +// except Think, where the running collapsed row follows the latest line at its +// scroll end and the summary yields while open to avoid repeating the body. // The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for // text input/output, the run_code program through CodeBlock, or a card // primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a @@ -19,7 +20,7 @@ // independent); an error row's collapsed summary is the failure's first line in // the error color. -import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, @@ -152,6 +153,7 @@ export function ToolRow({ inspect, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) + const summaryRef = useRef(null) const terminalBody = terminal ?? null const diffBody = diff ?? null const readBody = read ?? null @@ -173,6 +175,15 @@ export function ToolRow({ const summaryText = failureLine ?? summary // The failure line is error prose, not the path: no open-file affordance. const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null + const isThink = variant === 'think' + const followSummaryEnd = isThink && state === 'running' && !open + useLayoutEffect(() => { + const summaryElement = summaryRef.current + if (summaryElement === null) return + summaryElement.scrollLeft = followSummaryEnd + ? summaryElement.scrollWidth - summaryElement.clientWidth + : 0 + }, [followSummaryEnd, summaryText]) const toggleExpand = () => { setExpanded(v => !v) } @@ -188,9 +199,8 @@ export function ToolRow({ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() } // Think reasoning is prose, not an input payload: expanded, it renders as - // plain indented text (no IN/OUT card) and the inline summary — the body's - // own first line — yields to avoid repeating itself. - const isThink = variant === 'think' + // plain indented text (no IN/OUT card) and the inline summary yields to avoid + // repeating the body. // The code variant's program renders through CodeBlock (shiki), so only its // output joins the IN/OUT card; every other variant's input does too. const cardBody = variant === 'code' ? null : body @@ -227,7 +237,11 @@ export function ToolRow({ {summaryText} ) : ( - + {summaryText} )} 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 e0d01656ba..ff9a2a3f07 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -320,6 +320,42 @@ describe('ToolRow', () => { }) describe('ThinkRow', () => { + it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => { + const view = render( + , + ) + const summary = view.getByText('Newest reasoning tokens') + Object.defineProperties(summary, { + scrollWidth: { configurable: true, value: 300 }, + clientWidth: { configurable: true, value: 100 }, + }) + + view.rerender( + , + ) + expect(summary.scrollLeft).toBe(200) + expect(summary.getAttribute('data-follow-end')).toBe('true') + + view.rerender( + , + ) + expect(view.getByText('Inspect the session')).toBeTruthy() + expect(summary.scrollLeft).toBe(0) + expect(summary.hasAttribute('data-follow-end')).toBe(false) + }) + it('expands from either Think or the reasoning summary', () => { const view = render( & SubagentCatalogInjected + PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale interface CatalogRowsProps { parentSessionId: SessionId @@ -42,11 +43,14 @@ interface CatalogRowsProps { closeCatalog: () => void } -function diagnosticReason(entry: Extract): string { +function diagnosticReason( + entry: Extract, + t: TranslateNS, +): string { switch (entry.reason) { - case 'corrupt': return '会话记录损坏' - case 'unsupported': return '子代理记录版本不受支持' - case 'unavailable': return '会话记录暂不可用' + case 'corrupt': return t('diagnostic.corrupt') + case 'unsupported': return t('diagnostic.unsupported') + case 'unavailable': return t('diagnostic.unavailable') } } @@ -90,19 +94,26 @@ function activityDuration( } /** Format a non-negative duration to seconds without dropping larger units. */ -function formatDuration(ms: number): string { +function formatDuration(ms: number, t: TranslateNS): string { const totalSeconds = Math.floor(Math.max(0, ms) / 1_000) const seconds = totalSeconds % 60 const totalMinutes = Math.floor(totalSeconds / 60) const minutes = totalMinutes % 60 const hours = Math.floor(totalMinutes / 60) if (hours > 0) { - return `${hours}小时${String(minutes).padStart(2, '0')}分${String(seconds).padStart(2, '0')}秒` + return t('duration.hours', { + hours, + minutes: String(minutes).padStart(2, '0'), + seconds: String(seconds).padStart(2, '0'), + }) } if (totalMinutes > 0) { - return `${totalMinutes}分${String(seconds).padStart(2, '0')}秒` + return t('duration.minutes', { + minutes: totalMinutes, + seconds: String(seconds).padStart(2, '0'), + }) } - return `${seconds}秒` + return t('duration.seconds', { seconds }) } /** Aggregate the complete subagent-only descendant subtree from flat summaries. */ @@ -135,28 +146,30 @@ function CatalogLoadingRows({ parentSessionId, summaries, level, + t, }: { parentSessionId: SessionId summaries: Readonly> level: number + t: TranslateNS }) { const children = Object.values(summaries).filter(summary => ( summary.origin === 'subagent' && summary.parentId === parentSessionId )) - if (children.length === 0) return
正在加载子代理…
+ if (children.length === 0) return
{t('loading.label')}
return children.map(summary => (
- 正在加载子代理… + {t('loading.label')}
@@ -166,8 +179,8 @@ function CatalogLoadingRows({ /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, catalog, catalogs, summaries, expanded, level, now, - openChild, refresh, toggleBranch, closeCatalog, -}: CatalogRowsProps) { + openChild, refresh, toggleBranch, closeCatalog, t, +}: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 return ( <> @@ -176,24 +189,25 @@ function CatalogRows({ parentSessionId={parentSessionId} summaries={summaries} level={level} + t={t} /> )} {catalog.state === 'error' && (
- {catalog.error?.message ?? '无法加载子代理'} + {catalog.error?.message ?? t('load.error')}
)} {catalog.entries.map((entry) => { if (entry.kind === 'diagnostic') { - const reason = diagnosticReason(entry) + const reason = diagnosticReason(entry, t) return (
value !== undefined) .join(' · ') @@ -236,7 +250,7 @@ function CatalogRows({ ) const metrics = [ totalTokens === undefined ? undefined : `${formatTokens(totalTokens)} tok`, - durationMs === undefined ? undefined : formatDuration(durationMs), + durationMs === undefined ? undefined : formatDuration(durationMs, t), ].filter(value => value !== undefined).join(' · ') const open = (): void => { @@ -282,7 +296,7 @@ function CatalogRows({ type="button" tabIndex={-1} className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`} - aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`} + aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })} onClick={toggle} > @@ -309,6 +323,7 @@ function CatalogRows({ parentSessionId={entry.id} summaries={summaries} level={level + 1} + t={t} /> ) : ( @@ -324,6 +339,7 @@ function CatalogRows({ refresh={refresh} toggleBranch={toggleBranch} closeCatalog={closeCatalog} + t={t} /> )}
@@ -338,10 +354,10 @@ function CatalogRows({ /** * Render the current session's direct catalog and lazily expanded descendants. * @param props - session standard props plus catalog navigation actions. - * @returns The action only after a non-empty catalog arrives. + * @returns The action while the catalog is pending or summaries establish descendants. */ export function SubagentCatalogAction({ - sessionId, useSessions, openChild, refresh, setCatalogOpen, + sessionId, useSessions, openChild, refresh, setCatalogOpen, t, }: SubagentCatalogActionProps) { const catalogs = useSessions(state => state.subagentsByParent) const summaries = useSessions(state => state.byId) @@ -359,6 +375,20 @@ export function SubagentCatalogAction({ // The catalog can arrive before the session-list baseline; never undercount // the already-visible direct rows during that short bootstrap window. const descendantCount = Math.max(healthy.length, descendants.count) + const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other' + const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other' + // Session summaries can announce membership before the descriptor-backed catalog catches up. + // Keep that entry point visible through disabled loading rows; only catalog rows are navigable. + const summaryBackedLoading = descendants.count > 0 + && (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0)) + const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading + ? { + entries: [], + parentAvailable: catalog?.parentAvailable ?? false, + state: 'loading', + error: null, + } + : catalog const observeCatalog = (parentSessionId: SessionId, next: boolean): void => { if (next) observedCatalogs.current.add(parentSessionId) @@ -432,7 +462,8 @@ export function SubagentCatalogAction({ observedCatalogs.current.clear() }, []) - const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0) + const visible = presentedCatalog !== undefined + && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0) useEffect(() => { if (visible || !open) return setOpen(false) @@ -476,7 +507,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`} + aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -488,14 +519,14 @@ export function SubagentCatalogAction({ {descendants.running && } - {descendantCount} 个子代理 + {t(totalCountKey, { count: descendantCount })} {open && ( -
+
{ changeOpen(false) }} + t={t} />
)} diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 0cc2699bd8..158e8cb77b 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -1,4 +1,5 @@ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ @@ -8,7 +9,7 @@ export interface SubagentReadOnlyMatch { /** Full chain props after the read-only subagent selector accepts the owner currency. */ export type SubagentReadOnlyComposerProps = - PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } + PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale /** * Explain why the normal composer is unavailable for an addressed child. @@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps = * @returns A read-only composer replacement. */ export function SubagentReadOnlyComposer({ - matched, -}: Pick) { + matched, t, +}: Pick) { const oneShot = matched.reason === 'one-shot' return (
- {oneShot ? '一次性子代理记录' : '此子代理暂时只读'} + {t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {oneShot - ? '一次性任务不支持后续消息,可在这里查看完整执行记录。' - : '父会话当前不在线,重新打开父会话后即可继续发送消息。'} + {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 239ffd4335..31579dc258 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, NS, zh, type SubagentKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Subagent catalog and read-only composer copy. */ + 'subagent': SubagentKey + } +} export type { SubagentCatalogActionProps, SubagentCatalogInjected, @@ -27,7 +36,7 @@ export type { } from './SubagentReadOnlyComposer.tsx' /** Required services for references, conversation slots, and session navigation. */ -export const inject = ['slash', 'sessions', 'conversation', 'slots'] +export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries') const sessions = ctx.sessions // Child labels live on the session list (parentId lineage + displayTitle), // not the conversation snapshot — the list store is the zero-RPC candidate feed. @@ -98,6 +108,7 @@ export function apply(ctx: ClientContext): void { name: 'conversation.session.header.actions', id: 'subagent-catalog', order: 10, + locale: NS, inject: catalogActions, }, SubagentCatalogAction), 'ui-subagent: lazy descendant catalog action', @@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.composer', priority: -10, + locale: NS, select: selectReadOnlySubagent, }, SubagentReadOnlyComposer), 'ui-subagent: read-only addressed composer', diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts new file mode 100644 index 0000000000..5eaee7b0f1 --- /dev/null +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -0,0 +1,65 @@ +/** `subagent` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'subagent' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'diagnostic.corrupt': '会话记录损坏', + 'diagnostic.unsupported': '子代理记录版本不受支持', + 'diagnostic.unavailable': '会话记录暂不可用', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'duration.hours': '{hours}小时{minutes}分{seconds}秒', + 'loading.label': '正在加载子代理…', + 'loading.aria': '正在加载子代理', + 'load.error': '无法加载子代理', + 'retry': '重试', + 'mode.oneShot': '一次性', + 'mode.continuable': '可继续', + 'activity.running': '正在运行', + 'activity.inactive': '当前未运行', + 'branch.collapse': '收起 {label} 的下级子代理', + 'branch.expand': '展开 {label} 的下级子代理', + 'count.total.one': '{count} 个子代理', + 'count.total.other': '{count} 个子代理', + 'count.running.one': '{count} 个子代理,正在运行', + 'count.running.other': '{count} 个子代理,正在运行', + 'tree.aria': '子代理会话', + 'readonly.oneShot.title': '一次性子代理记录', + 'readonly.title': '此子代理暂时只读', + 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', + 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'diagnostic.corrupt': 'corrupted session record', + 'diagnostic.unsupported': 'unsupported subagent record version', + 'diagnostic.unavailable': 'session record temporarily unavailable', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m {seconds}s', + 'loading.label': 'Loading subagents…', + 'loading.aria': 'Loading subagents', + 'load.error': 'Unable to load subagents', + 'retry': 'Retry', + 'mode.oneShot': 'one-shot', + 'mode.continuable': 'continuable', + 'activity.running': 'running', + 'activity.inactive': 'not running', + 'branch.collapse': 'Collapse {label} descendants', + 'branch.expand': 'Expand {label} descendants', + 'count.total.one': '{count} subagent', + 'count.total.other': '{count} subagents', + 'count.running.one': '{count} subagent running', + 'count.running.other': '{count} subagents running', + 'tree.aria': 'Subagent sessions', + 'readonly.oneShot.title': 'One-shot subagent record', + 'readonly.title': 'This subagent is read-only for now', + 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', + 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', +} + +/** Key domain of the `subagent` namespace (zh is the source of truth). */ +export type SubagentKey = keyof typeof zh diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 0db157a5a7..d2332cb3af 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -18,6 +18,7 @@ import { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, } from '../src/client/SubagentCatalogAction.tsx' @@ -85,6 +86,7 @@ async function fullBench(sessions: SessionSummary[]) { ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { source: captured!, face, ctx } } @@ -111,7 +113,7 @@ const req = (query: string) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots']) + expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale']) }) it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { @@ -119,6 +121,7 @@ describe('apply', () => { await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index d7098c1547..9716b6bbf3 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' @@ -8,6 +9,7 @@ import { SubagentCatalogAction, type SubagentCatalogActionProps, } from '../src/client/SubagentCatalogAction.tsx' import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx' +import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() @@ -18,6 +20,7 @@ afterEach(() => { const PARENT = 'parent' as SessionId const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId +const t: SubagentCatalogActionProps['t'] = makeTranslate(zh) function catalog(over: Partial = {}): SubagentCatalogSnapshot { return { @@ -70,6 +73,7 @@ function props( openChild: vi.fn(), refresh: vi.fn(), setCatalogOpen: vi.fn(), + t, } as unknown as SubagentCatalogActionProps } @@ -155,6 +159,24 @@ describe('SubagentCatalogAction', () => { expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false) }) + it('selects singular count keys for one descendant', () => { + const base = props(catalog({ + entries: [{ + kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', + activity: 'running', hasChildren: false, + }], + }), {}, { + [CHILD]: { + ...summary(CHILD, Date.now()), parentId: PARENT, origin: 'subagent', running: true, + }, + }) + const translate = vi.fn(base.t) + render() + + expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 }) + expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 }) + }) + it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => { const input = props(catalog()) render() @@ -416,6 +438,32 @@ describe('SubagentCatalogAction', () => { expect(failed.refresh).toHaveBeenCalledWith(PARENT) }) + it('keeps known descendants reachable while their catalog is absent or stale-empty', () => { + const second = 'child-2' as SessionId + const summaries = { + [CHILD]: { + ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const, + }, + [second]: { + ...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true, + }, + } + const absent = props(undefined, {}, summaries) + const view = render() + + const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' }) + fireEvent.click(trigger) + expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true) + expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) + expect(absent.openChild).not.toHaveBeenCalled() + + const staleEmpty = props(catalog({ entries: [] }), {}, summaries) + view.rerender() + expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy() + expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) + expect(staleEmpty.openChild).not.toHaveBeenCalled() + }) + it('renders empty loading and fallback error states without focusable rows', async () => { const loading = props(catalog({ entries: [], state: 'loading' })) const view = render() @@ -469,12 +517,12 @@ describe('SubagentCatalogAction', () => { describe('SubagentReadOnlyComposer', () => { it('explains the exact missing-parent recovery path', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) it('explains that one-shot histories never accept follow-ups', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') }) }) diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 0cae499e1a..27d06d16c0 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../runtime" }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 786d4431de..7091f95447 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1607,6 +1607,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', }, + { + name: 'AgentSetup', + declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', + }, + { + name: 'AgentSetupCommit', + declaration: 'export interface AgentSetupCommit {\n commit(): void;\n}', + }, { name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', @@ -1833,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -2337,7 +2345,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'SandboxEnforcement', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 691115619b..81f5271097 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: c71b350adfe06a19d4c24cb7e67de895a662bd87 -README.zh.md: d30dfc85e1597a8e193019cc23f4c7c39c991776 +README.md: 2ce85071c4b7408adb4ee05291c499ec642be114 +README.zh.md: bc78c02fc046f3bb5820f89bae5a90b26b5a8ced diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c71b350adf..2ce85071c4 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. +Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. @@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index d30dfc85e1..bc78c02fc0 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -10,7 +10,7 @@ ### 公开 API -创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 +创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose(资源释放)或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 @@ -20,8 +20,8 @@ `AgentLoop` 还实现 `AgentFactory` seam,并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent: -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),使用同一 id 注册 agent,重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup,再执行受回滚保护的发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),在同一 id 下重建历史,针对全新且尚未发布的 agent 作用域等待 setup,调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent(该路径会丢弃 handle)。对于以编程方式创建的 agent,handle 持有者是唯一面向消费方的 teardown 能力;AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 1342fbd885..54b8f5c957 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -558,7 +558,10 @@ export class AgentLoop extends Service implements AgentFactory { const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal) const published = (async () => { try { - await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId) + const setupCommit = await raceAbort( + options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId, + ) + setupCommit?.commit() return prepared.publish('startup') } catch (error: unknown) { await prepared.dispose() @@ -617,7 +620,8 @@ export class AgentLoop extends Service implements AgentFactory { }) const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal) try { - await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + setupCommit?.commit() return prepared.publish('resume') } catch (error: unknown) { await prepared.dispose() diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 7844aebd73..0bb7ce7d2e 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -291,6 +291,13 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setupStarted.resolve(undefined) await gate.promise order.push('setup:end') + return { + commit: () => { + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + order.push('setup:commit') + }, + } }, }) @@ -304,6 +311,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(order).toEqual([ 'setup:start', 'setup:end', + 'setup:commit', 'session/created', 'setup-listener:session/created', 'agent/created', @@ -359,6 +367,33 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('resume setup commit rejection publishes nothing and releases the identity', async () => { + const sessionId = SessionId('resume-setup-commit-reject') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + await expect(ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + setup: () => ({ + commit: () => { throw new Error('resume setup commit failed') }, + }), + })).rejects.toThrow('resume setup commit failed') + + expect(published).toEqual([]) + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + const retry = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await retry.dispose() + await ctx.fiber.dispose() + }) + it('owner unload aborts resume setup and cannot publish after the callback settles', async () => { const sessionId = SessionId('resume-setup-owner-unload') const root = await persistSession(sessionId) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 5f4e51784a..2420d20e8f 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -264,6 +264,13 @@ describe('agent scope lifecycle', () => { setupStarted.resolve(undefined) await gate.promise order.push('setup:end') + return { + commit: () => { + expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined() + order.push('setup:commit') + }, + } }, }) await setupStarted.promise @@ -276,6 +283,7 @@ describe('agent scope lifecycle', () => { expect(order).toEqual([ 'setup:start', 'setup:end', + 'setup:commit', 'session/created', 'setup-listener:session/created', 'agent/created', diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index f8431cf022..8673595322 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 4c6a6dd95541cfa559e95858fede01d7cd76637f -README.zh.md: 07bf887c557b410005bbe0fa1a988e63a765ad99 +README.md: 98421aa6de3d6778702665854ed723507e933028 +README.zh.md: bfc8d68a9656a29a809de0848986e4ee9eb3fe7c diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 4c6a6dd955..98421aa6de 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves. `AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. @@ -39,8 +39,8 @@ The scope carries the `Agent` itself and is process-local. Ambient presence is n Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, invoke its optional synchronous commit, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, invoke its optional synchronous commit, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 07bf887c55..bfc8d68a96 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 ### 公开 API -带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 `AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 @@ -39,8 +39,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上,消费方(UI、ACP 桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow,也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。 - `ctx.agents.setFactory(factory: AgentFactory): () => void`:注册创建工厂(循环在构造时调用)。第二个工厂会导致抛出;dispose 时清空槽位。 -- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 -- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 +- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,调用其可选的同步提交,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 +- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 `AgentHandle = { agent: Agent; dispose(): Promise }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent,从存储中移除其会话,最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。 diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index d5c19aae3e..66dee4efb7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -36,6 +36,27 @@ declare module 'cordis' { } } +/** + * Synchronous finalizer returned by unpublished Agent setup when its + * contributions need validation at the exact publication commit point. + */ +export interface AgentSetupCommit { + /** + * Validate and commit the prepared setup immediately before publication. + * @throws when publication must roll the unpublished Agent back. + */ + commit(): void +} + +/** + * Compose an unpublished Agent scope and optionally return its publication commit. + * @param agentCtx - unpublished Agent scope. + * @returns an optional synchronous commit invoked after setup awaits settle and immediately before publication. + */ +export type AgentSetup = ( + agentCtx: Context, +) => AgentSetupCommit | Promise | void + /** * Options for programmatically creating an agent through the registry factory * ({@link AgentRegistry.create}). The caller supplies the single live @@ -80,17 +101,21 @@ export interface CreateAgentOptions { * Creation-time composition of the agent's scoped world. The factory awaits * setup after minting `agentCtx` but BEFORE inserting or announcing either * the session or agent, so observers can never see a partially configured - * world. Everything registered through `agentCtx` (scoped tools, prompt - * sections/variables, `restrict()`, listeners, awaited child plugins) exists - * before `session/created`, `agent/created`, `agent/session-start`, and the - * first prompt assembly. A throw/rejection or owner disposal rolls the scope - * back without publishing either id. + * world. Setup may return an {@link AgentSetupCommit}; the factory invokes its + * synchronous `commit()` after every setup await settles and immediately + * before registry publication. This lets mutable provisioning revalidate at + * the exact publication boundary. Everything registered through `agentCtx` + * (scoped tools, prompt sections/variables, `restrict()`, listeners, awaited + * child plugins) exists before `session/created`, `agent/created`, + * `agent/session-start`, and the first prompt assembly. A setup + * throw/rejection, commit throw, or owner disposal rolls the scope back + * without publishing either id. * * **Setup composes, it never drives**: the callback is trusted same-process * code and receives the full scoped context, so this is a contract rather * than a runtime restriction. Drive the agent only after creation resolves. */ - readonly setup?: (agentCtx: Context) => Promise | void + readonly setup?: AgentSetup } /** @@ -108,12 +133,12 @@ export interface ResumeAgentOptions { * Resume-time composition of the agent's fresh scoped world. Persistence is * loaded first; the factory then mints `agentCtx` and awaits setup while the * reconstructed session and agent remain unpublished. The callback has the - * same trusted composition-only contract as - * {@link CreateAgentOptions.setup}: all registrations exist before either - * creation announcement, and rejection or owner disposal rolls the - * transaction back without publishing either id. + * same trusted composition-only contract and optional synchronous + * publication commit as {@link CreateAgentOptions.setup}: all registrations + * exist before either creation announcement, and rejection, commit failure, + * or owner disposal rolls the transaction back without publishing either id. */ - readonly setup?: (agentCtx: Context) => Promise | void + readonly setup?: AgentSetup } /** @@ -144,9 +169,9 @@ export interface AgentHandle { export interface AgentFactory { /** * Create a new agent on a caller-supplied session id. Async because creation - * awaits unpublished setup, inserts both session and agent, emits their - * creation notifications in order, emits `agent/session-start`, and only - * then starts the loop. The sequence is + * awaits unpublished setup, invokes its optional synchronous commit, inserts + * both session and agent, emits their creation notifications in order, emits + * `agent/session-start`, and only then starts the loop. The sequence is * rollback-covered, but notifications delivered before a later listener * failure remain observable; every agent or session creation announcement * that began is paired by `agent/disposed` or `session/disposed` during @@ -165,8 +190,8 @@ export interface AgentFactory { * Load a persisted session and resume an agent on it. Async because it awaits * both `ctx.sessionPersistence.load` and the optional unpublished setup * transaction; must be called after that service exists (consumers inject - * `sessionPersistence`). Publication follows the same ordered boundary as - * {@link createAgent}. + * `sessionPersistence`). Publication follows the same setup-commit and + * ordered boundary as {@link createAgent}. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index d26f891f91..8fa82352b8 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda -README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a +README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f +README.zh.md: 1f0791c5df7afd4a3479afdd827c4fc148cf8883 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 15fc5839a3..80ea3cc934 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -43,7 +43,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately. +- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. It defers one context until the tool's final result reaches the loop — typically a nested-dispatch context ferried by a composite tool, or a fresh plugin-sourced instruction minted by a leaf tool (`tool-goal`'s wrap-up) — even when the tool later throws or cancellation wins; it never injects immediately. - `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute identified `UserMessage` for the loop's post-result FIFO. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 8547ee4a79..1f0791c5df 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -43,7 +43,7 @@ tools: - `ToolExecutionInput`:调用方提供的调用描述:`{ callId, name, arguments, signal, agent?, parent? }`;`signal` 必填且只读,调用方可以将外层执行的不透明 token 作为 `parent` 传入,但绝不能选择新执行自身的 token。 - `ToolExecutionToken`:注册表分配的全新带品牌 `Symbol`。它只支持通过相等性进行关联,绝不会跨越模型、日志或 worker 边界。 - `ToolExecution`:只读流水线视图:不可变的 `{ token, callId, name, arguments, signal, agent?, parent? }`;注册表会另行保留并重新融合调用方的原始信号。`ToolDispatchExecution` 是仅供 `tools/execute` 使用的视图,其必填信号可变,因此包装层可以替换并还原它,但不能删除它。嵌套调用的 `parent` 是 `ToolExecutionToken`,而不是执行对象。 -- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。组合工具借此把嵌套分发产生的上下文传递到外层结果,即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 +- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。它把一条上下文推迟到该工具的最终结果抵达循环时——通常是组合工具转运的嵌套分发上下文,也可以是叶子工具铸造的全新插件来源指令(如 `tool-goal` 的收尾注入)——即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 - `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError`;`additionalContexts` 会保留每个通过延迟或 post-execute 加入且带标识的 `UserMessage`,供循环在结果后按 FIFO 顺序处理。 - `PreToolDecision`:`{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../ui/user-approval/README.md) 时由它处理,否则退化为拒绝。 - `PostToolDecision`:接受决定可以替换 `content` 或 `value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index f30dce6cd0..72254e2abd 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -344,15 +344,18 @@ export interface ToolDispatchExecution extends Omit { /** * 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`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ export 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. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — 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: UserMessage): void /** diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 3b9dd9f964..354456002b 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/goal/tool-goal/README.md -README.md: aaed61dd517aeb2f94efa22c34c64d1068155d46 -README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8 +README.md: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80 +README.zh.md: c6c39e3cc739fb39a4a36080db5246e7c7349147 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index aaed61dd51..2fa80c2e5f 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and 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. -An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. +An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority @@ -61,11 +61,11 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar #### What the model sees -The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. +The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one ``/`` wrap-up instruction that asks for a grounded closing message to the user without further tool calls. #### Token effect -Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. +Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round. #### KV Cache effect diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 5365b64ef6..c6c39e3cc7 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -14,7 +14,7 @@ 3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }` 或 `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON,即可收到相同领域结构。 -自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会用 `concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更绝不会导致这种停止:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 +自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 ## 权限 @@ -61,11 +61,11 @@ Use goal tools for one long-running completion objective in the current session. #### 模型看到的内容 -生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。 +生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 ``/`` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。 #### Token 影响 -固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。 +固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。Goal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。 #### KV Cache 影响 diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index e62510b06f..9ceed21bc9 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -17,6 +17,7 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' +import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -309,7 +310,14 @@ export function apply(ctx: Context, config: Config): void { code: 'model-reported', message: args.blocked_reason as string, }) - if (authority.kind === 'goal-round') exec.concludeTurn() + if (authority.kind === 'goal-round') { + exec.deferContext(createUserMessage({ + content: args.action === 'complete' + ? renderWrapupContext(goal.objective) + : renderWrapupContext(goal.objective, args.blocked_reason as string), + source: { kind: 'plugin', plugin: 'tool-goal' }, + })) + } return Promise.resolve(goalValue(goal)) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/src/wrapup.ts b/packages/goal/tool-goal/src/wrapup.ts new file mode 100644 index 0000000000..158f17e886 --- /dev/null +++ b/packages/goal/tool-goal/src/wrapup.ts @@ -0,0 +1,41 @@ +/** Model-visible wrap-up instruction for a terminal autonomous goal update. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +const GROUNDING = + 'Report only what earlier rounds and tool results in this session actually establish; ' + + 'when a detail is not in the session, say so instead of inventing it. ' + +/** + * Render the closing-message instruction injected after an autonomous goal + * round reports `complete` or `blocked`, replacing the former hard turn stop + * so the model still addresses the user once before the turn ends. + * @param objective - the terminal goal's objective, echoed for grounding. + * @param blockedReason - the validated report for `blocked`; omitted for `complete`. + * @returns a fresh one-block context for `ToolRunContext.deferContext()`. + */ +export function renderWrapupContext(objective: string, blockedReason?: string): ContentBlock[] { + const heading = `Objective: ${JSON.stringify(objective)}\n` + const text = blockedReason === undefined + ? '\n' + + heading + + 'The goal is marked complete and this autonomous run is ending. Write the closing ' + + 'message to the user now: state the outcome, summarize what was done and how it was ' + + 'verified, and point to the concrete results (files, commits, or other artifacts). ' + + GROUNDING + + 'Note anything the user should review or do next. Address the user directly. Do not ' + + "call any more tools in this run; further work waits for the user's next instruction.\n" + + '' + : '\n' + + heading + + `Blocked: ${JSON.stringify(blockedReason)}\n` + + 'The goal is marked blocked and this autonomous run is ending. Write the closing ' + + 'message to the user now: state what has been completed so far, describe the concrete ' + + 'blocking condition and what you tried, and say exactly what you need from the user to ' + + 'continue. ' + + GROUNDING + + 'Address the user directly. Do not call any more tools in this run; further work ' + + "waits for the user's next instruction.\n" + + '' + return [{ type: 'text', text }] +} diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 4278fc5a50..9363081f85 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -347,7 +347,7 @@ describe('goal tool state transitions', () => { expect(goal).toMatchObject({ phase: 'active', revision: 4 }) }) - it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { + it('injects one wrap-up instruction for an autonomous completion but leaves a human pause interactive', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) @@ -356,6 +356,7 @@ describe('goal tool state transitions', () => { }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) expect(paused.concludesTurn).toBeUndefined() + expect(paused.additionalContexts).toBeUndefined() const resumed = resultGoal(await execute(ctx, 'update_goal', { goal_id: created.id, revision: 2, action: 'resume', }, root.agent)) @@ -368,7 +369,27 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: resumed['revision'], action: 'complete', }, root.agent) expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) - expect(complete.concludesTurn).toBe(true) + expect(complete.concludesTurn).toBeUndefined() + const contexts = complete.additionalContexts ?? [] + expect(contexts).toHaveLength(1) + expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' }) + const block = contexts[0]?.content[0] + if (block?.type !== 'text') throw new Error('expected one text wrap-up block') + expect(block.text).toContain('') + expect(block.text).toContain('"pause cleanly"') + expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.") + }) + + it('completes without a wrap-up instruction under direct human authority', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'finish now' }) + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) + expect(complete.concludesTurn).toBeUndefined() + expect(complete.additionalContexts).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { @@ -550,6 +571,14 @@ describe('goal tool state transitions', () => { blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' }, roundsStarted: 3, }) + expect(blocked.concludesTurn).toBeUndefined() + const contexts = blocked.additionalContexts ?? [] + expect(contexts).toHaveLength(1) + const block = contexts[0]?.content[0] + if (block?.type !== 'text') throw new Error('expected one text wrap-up block') + expect(block.text).toContain('') + expect(block.text).toContain('The required credential is still unavailable.') + expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.") }) it('lets direct human authority block before the model threshold', async () => { @@ -570,5 +599,7 @@ describe('goal tool state transitions', () => { }, roundsStarted: 0, }) + expect(blocked.concludesTurn).toBeUndefined() + expect(blocked.additionalContexts).toBeUndefined() }) }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 124f3a5fd0..e037167c6c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1034,8 +1034,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Whether the session's own suffix carries the durable subagent discriminator. */ function hasSubagentDescriptor(session: Pick): boolean { - const ownStart = session.header.seedLength ?? 0 - return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor') + const events = session.events + // Indexed scan from the own-suffix start: slicing copies the whole suffix + // on every Agent-bound RPC, including each `session.prompt` on long + // transcripts. + for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) { + if (events[index]?.type === 'subagent/descriptor') return true + } + return false } /** @@ -1076,13 +1082,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return inspected } - async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { - const attached = ctx.sessions.get(sessionId) + /** + * Resolve one live registered identity through the subagent-ownership + * fence: subagent-owned agents answer `agent-busy`, plain agents pass. + * Fences the live agent's own session rather than trusting a + * "registered ⇒ attached-store" invariant — a registered subagent whose + * session is ever absent from the attached store must still not be handed + * out through generic Host routing. `undefined` means no live agent. + */ + function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined { const live = ctx.agents.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, live)) { + if (live === undefined) return undefined + if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } + return { agent: live } + } + + async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { return { error: subagentOwnershipError(sessionId) } } - if (live !== undefined) return { agent: live } let resume = resumes.get(sessionId) if (resume === undefined) { resume = (async () => { @@ -1117,6 +1138,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (error instanceof SubagentSessionOwnership) { return { error: subagentOwnershipError(error.sessionId) } } + // A concurrent publish can win the identity between the pre-resume + // re-check and `ctx.agents.resume` publication; the ID-collision + // rejection falls through here. Mirror ensureSession's `.catch` in + // full: classify a subagent-owned winner into the stable ownership + // error, and hand a clean plain-agent winner straight back. + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { + return { error: subagentOwnershipError(sessionId) } + } // The internal details slot is contractually {}; the reason rides the message. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } } } @@ -1192,13 +1224,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ? undefined : (await persistence.list()).find(header => header.id === sessionId) if (persistence !== undefined && stored !== undefined) { - if (stored.cwd !== cwd) { - throw new SessionCwdConflict(sessionId, cwd, stored.cwd) - } const inspected = await persistence.inspect(sessionId) + // Ownership first: explicit-id adoption of a session-backed + // subagent must answer `agent-busy` regardless of the requested + // cwd (the api/commands.ts contract), not a cwd conflict. if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { throw new SubagentSessionOwnership(sessionId) } + if (inspected.meta.cwd !== cwd) { + throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) + } return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, @@ -2266,9 +2301,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, commands: { - // Both methods address one session's agent (agentFor keeps its - // resume-on-miss: clients only send a sessionId for a published - // session, and resume restores an existing entity). + // Both methods address one session's agent. agentFor resumes on miss + // and fences every subagent-owned identity with `agent-busy`; the + // api/commands.ts module contract owns that fence's wording, so this + // comment only notes the routing shape: clients send a sessionId for a + // published session, and resume restores an existing entity. async list(request) { // Missing service = the deployment omitted dsh-commands from its // composition, not an empty catalog: fail loud instead of serving []. diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 1e5b095778..7eeeba3af6 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -338,4 +338,43 @@ describe('sessions.prompt synchronous rejection', () => { } } }) + + it('classifies a raced cold-resume ID collision as agent-busy', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('race-resume') + const meta: SessionHeader = header('race-resume', 1000) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), + locate: () => undefined, + } as never) + // The raced winner: a live parent-owned subagent publishes the identity + // while the generic cold resume is in flight, so the resume collides. + const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } }) + const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent + ctx.agents.register(parent) + const childSession = ctx.sessions.create(sessionId, { + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, + }) + const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + // The parent's `enter()` wins the identity between the pre-resume + // re-check and publication; the generic resume then collides. + ctx.agents.register(child) + throw new Error('session id already published') + }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const models = await api.sessions.models(request({ sessionId })) + expect(models.result.ok).toBe(false) + if (!models.result.ok) { + expect(models.result.error).toMatchObject({ + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }) + } + }) }) diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index c0fc84552c..5681e89863 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -12,6 +12,7 @@ */ import type { Context } from 'cordis' +import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import { SubagentError } from './error.ts' @@ -47,17 +48,6 @@ interface TransactionState { invalidated: boolean } -/** Package-private setup transaction consumed by the continuation manager. */ -export interface ActivationSetupTransaction { - /** - * Reject a batch invalidated by revocation before publication. - * @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation. - */ - assertIntact(): void - /** Promote this batch to resident installations. */ - commit(): void -} - /** Re-read mutable removal state after a contribution may have revoked itself. */ function isRemoved(registration: Registration): boolean { return registration.removed @@ -95,9 +85,9 @@ export class SubagentActivationSetupRegistry { /** * Install every live contribution into one unpublished child context. * @param childCtx - the child's unpublished scoped context. - * @returns the provisioning transaction. + * @returns the provisioning commit consumed at Agent publication. */ - apply(childCtx: Context): ActivationSetupTransaction { + apply(childCtx: Context): AgentSetupCommit { const state: TransactionState = { installations: [], invalidated: false } try { for (const registration of [...this.registrations]) { @@ -135,15 +125,14 @@ export class SubagentActivationSetupRegistry { } childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') return { - assertIntact: () => { - if (!state.invalidated) return - throw new SubagentError( - 'a continuable-subagent setup contribution was revoked while this child was being built; ' - + 'the child was not established', - 'ACTIVATION_SETUP_REVOKED', - ) - }, commit: () => { + if (state.invalidated) { + throw new SubagentError( + 'a continuable-subagent setup contribution was revoked while this child was being built; ' + + 'the child was not established', + 'ACTIVATION_SETUP_REVOKED', + ) + } for (const installation of state.installations) installation.transaction = undefined }, } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index d21f235589..f690cab6cf 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -20,6 +20,7 @@ import type { Agent, AgentHandle, AgentOptions, + AgentSetupCommit, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' @@ -42,7 +43,6 @@ import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequ import type { ActivationObserver } from './lifecycle.ts' import { SubagentError } from './error.ts' import type SubagentActivationSetupRegistry from './activation-setup-registry.ts' -import type { ActivationSetupTransaction } from './activation-setup-registry.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { @@ -800,10 +800,9 @@ export class SubagentContinuationManager { // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() - let setupTransaction!: ActivationSetupTransaction - const setup = (childCtx: Context): void => { + const setup = (childCtx: Context): AgentSetupCommit => { applyChildComposition(childCtx, inputs.composition) - setupTransaction = this.setupRegistry.apply(childCtx) + return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) const { create } = inputs @@ -842,7 +841,6 @@ export class SubagentContinuationManager { try { inputs.signal.throwIfAborted() this.assertAdmitting(parent) - setupTransaction.assertIntact() this.acquireOwnership(parent, childId) // Every accepted id leaves the inbox exactly once, through dequeue or // discard. Clearing it there is what lets `stateOf()` distinguish a truly @@ -860,8 +858,8 @@ export class SubagentContinuationManager { for (const item of items) activation.accepted.delete(item.message.id) this.wake(activation) }) - // Resident setup revokes live from here instead of invalidating creation. - setupTransaction.commit() + // Agent creation committed setup at its publication boundary; + // revocations from here on are immediate live revocation. // Publish the start edge before any turn can run, so observers see this // epoch before its first request. observer.start(handle.agent) diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 22acbecbdd..55ba73dcc4 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -12,6 +12,11 @@ * omits `subagentDepth` — cold resume trusts the persisted header's * `delegationDepth` as the monotone floor — and `outputSchema`, which belongs * to one activation's result contract rather than durable child composition. + * Per-activation knobs such as `maxTokens` are omitted for the same reason as + * `outputSchema`: they budget one activation. Cold resume requires the exact + * live parent for authorization but reconstructs child options only from the + * durable descriptor, so it neither restores the prior budget nor inherits + * the parent's current one; the resumed route's defaults apply instead. * * @module @deepseek-ai/dsh-subagent/descriptor */ diff --git a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts index 059befe236..6353f486c5 100644 --- a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts +++ b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts @@ -19,8 +19,7 @@ describe('SubagentActivationSetupRegistry', () => { const transaction = registry.apply(child.ctx) expect(order).toEqual(['first', 'second']) - expect(() => { transaction.assertIntact() }).not.toThrow() - transaction.commit() + expect(() => { transaction.commit() }).not.toThrow() expect(order).toEqual(['first', 'second']) }) @@ -68,7 +67,7 @@ describe('SubagentActivationSetupRegistry', () => { remove() expect(disposals).toBe(1) - expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/) + expect(() => { transaction.commit() }).toThrow(/revoked while this child was being built/) }) it('catches a contribution revoked inside its own installer', () => { @@ -82,7 +81,7 @@ describe('SubagentActivationSetupRegistry', () => { const transaction = registry.apply(childContext().ctx) expect(disposals).toBe(1) - expect(() => { transaction.assertIntact() }).toThrow(/revoked/) + expect(() => { transaction.commit() }).toThrow(/revoked/) }) it('attempts every contribution-removal disposer before reporting failures', () => { diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 849f6de67c..869dee55bf 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md -README.md: e15b8b5d5881fd7b6868995fec22048a605f4c7e -README.zh.md: 0c41bc9c1e5aa4d728789b064f2d00c8da8ca6c8 +README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58 +README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index e15b8b5d58..cd73154dfb 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package. -A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A missing, disposed, or closing parent fails the call with `direct parent is not live; report was not delivered`; the service performs no injection, parent cold resume, or offline mailbox write, so the durable child transcript remains the recovery source. +A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). `reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. @@ -58,7 +58,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del ## Known Limitations and Deferred Work -- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication. - **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary. - **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report. - **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 0c41bc9c1e..4b31bed48e 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -4,7 +4,7 @@ 可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 -子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级不存在、已 dispose(资源释放)或正在关闭时,本次调用会失败并返回 `direct parent is not live; report was not delivered`;服务不会执行注入、父级冷恢复或离线 mailbox 写入,因此持久化子级 transcript(文本记录)仍是恢复真源。 +子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 `reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 @@ -58,7 +58,6 @@ ## 已知限制与暂缓事项 -- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口,需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。 - **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由延续管理器拥有的父级,管理器的准入边界会在整棵子树拆卸期间拒绝该上报。 - **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。 - **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。 diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index e83d65f830..962d8cf382 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -88,7 +88,10 @@ export function installReportTool( * @param config - deployment scheduling policy. */ export function apply(ctx: Context, config: Config = {}): void { - const { reportDelivery = 'quiet' } = Config(config) + // Config() applies the schema default ('quiet') at runtime; the schemastery + // return type keeps the input's optional shape, so assert the resolved + // shape here — no runtime fallback exists or is wanted. + const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery } ctx.subagents.registerContinuableSetup(childCtx => installReportTool(childCtx, ctx, reportDelivery)) } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 31837afa9d..84f33646bb 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -327,6 +327,15 @@ describe('dsh-tool-subagent-report', () => { return dispose }) + // No session may be announced for the rejected child: the setup + // validation must reject inside the creation callback, before the factory + // publishes — a post-publication rejection would persist a resumable + // ghost that `list_agents` surfaces and `send_message` can resurrect. + // The parent was created inside setup(), so any later announcement is the + // rejected child's. + const announced: SessionId[] = [] + const listener = (session: { id: SessionId }): void => { announced.push(session.id) } + const removeListener = ctx.on('session/created', listener) await expect(ctx.subagents.startContinuable({ provider: 'spawn', label: 'racing child', @@ -336,9 +345,56 @@ describe('dsh-tool-subagent-report', () => { }, signal: testSignal, })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' }) + removeListener() + expect(announced).toEqual([]) expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) }) + it('rolls back materialization when setup revocation lands before publication', async () => { + const { ctx, parent } = await setup({ load: false }) + const self: { revoke?: () => void } = {} + let installed = false + self.revoke = ctx.subagents.registerContinuableSetup(() => { + installed = true + queueMicrotask(() => { self.revoke?.() }) + return () => { installed = false } + }) + const announced: SessionId[] = [] + const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) }) + + await expect(ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'revoked child', + request: { + prompt: [{ type: 'text', text: 'revoked child' }], + parent, + }, + signal: testSignal, + })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' }) + removeListener() + expect(installed).toBe(false) + expect(announced).toEqual([]) + expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) + expect(ctx.sessions.list()).toEqual([parent.session]) + }) + + it('accepts a report into a host-disposing but still-registered parent', async () => { + const { ctx } = await setup() + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('disposing-parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const { child } = await startChild(ctx, parentHandle.agent) + // Host-owned disposal starts asynchronously; the parent stays registered + // until quiescence, and registry presence — not disposal state — is the + // acceptance gate (pins the README contract). + const disposing = parentHandle.dispose() + const accepted = await callReport(ctx, child, 'during-close') + expect(accepted.isError).toBe(false) + await disposing + expect((await callReport(ctx, child, 'after-close')).isError).toBe(true) + }) + it('keeps the namespace plugin shape and validates its default', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-report') diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index e4f6711ea7..507c3da1a5 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/llm-replay/README.md -README.md: 0deb6e76b29d40483b754ac01c98ee0e01bfcbe8 -README.zh.md: 7720e2d1bc6eb7bc5c89d5c1708767a54a7b0080 +README.md: 85aa56705929e7630e4cfb6c2a3c9cbbd0d843a6 +README.zh.md: 751f75dea197ffb112cfa703e3a5dbfaffb8c0b2 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 0deb6e76b2..85aa567059 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -12,6 +12,8 @@ The fixture IS the persisted session log (`/session.jsonl`). Its `assi Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. +A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud. The last two braces of a consecutive `}` run terminate the placeholder, so a pattern may end with a brace quantifier (`[0-9a-f]{4}`) but cannot contain `}}` followed by further pattern content. Resolution applies to every scripted entry, including ones derived from the recorded JSONL — a recorded fixture whose text legitimately contains the literal marker must be expressed through a sidecar without it. + ## Nested agents: per-session keying A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script. @@ -55,7 +57,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s - `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. - `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. - Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 7720e2d1bc..751f75dea1 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -12,6 +12,8 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as 有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 +脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错。连续右花括号串的最后两个花括号才是占位符结束符,因此模式可以以花括号量词收尾(如 `[0-9a-f]{4}`),但不能在 `}}` 之后还有后续模式内容。解析作用于所有脚本条目,包括从已记录 JSONL 派生的条目——若录制文本本身合法地含有该字面量标记,需改用不含标记的伴随文件表达。 + ## 嵌套 agent:每会话键控 父 agent 委托给进程内 subagent(子 agent)的场景会记录多个日志:父会话使用 `session.jsonl`,每个子会话各使用一个日志(`session.1.jsonl` 等)。每个 agent 都在同一上下文中作为独立的 `Session` 运行,因此回放必须为每个 agent 提供各自的脚本。 @@ -55,7 +57,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 - `loadSessionScripts(config)`:解析场景的有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 - `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。 -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)`:将已记录会话日志转换为脚本并读取其 header `id`/`createdAt` 的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 - 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index bfb9859415..4b89a844bf 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -241,6 +241,96 @@ const REPLAY_CHUNK_TYPES = new Set([ 'finish', ]) +const FROM_REQUEST_OPEN = '{{fromRequest:' +const FROM_REQUEST_CLOSE = '}}' + +/** Collect every string leaf of one JSON-shaped value, in traversal order. */ +function collectStrings(value: unknown, out: string[]): void { + if (typeof value === 'string') { + out.push(value) + return + } + if (Array.isArray(value)) { + for (const item of value) collectStrings(item, out) + return + } + if (value !== null && typeof value === 'object') { + for (const item of Object.values(value)) collectStrings(item, out) + } +} + +/** Resolve one placeholder pattern against the request corpus; the LAST match wins. */ +function resolveFromRequest(pattern: string, corpus: string): string { + let regex: RegExp + try { + regex = new RegExp(pattern, 'g') + } catch (error) { + // RegExp construction only throws SyntaxError; String() carries its message. + throw new Error(`llm-replay: fromRequest has an invalid pattern ${JSON.stringify(pattern)}: ${String(error)}`) + } + let last: RegExpExecArray | undefined + for (const match of corpus.matchAll(regex)) last = match + if (last === undefined) { + throw new Error(`llm-replay: fromRequest pattern ${JSON.stringify(pattern)} matched nothing in the request`) + } + return last[1] ?? last[0] +} + +/** Replace every `{{fromRequest:}}` occurrence in one scripted string. */ +function substituteString(text: string, corpus: string): string { + let result = '' + let cursor = 0 + while (true) { + const open = text.indexOf(FROM_REQUEST_OPEN, cursor) + if (open === -1) return result + text.slice(cursor) + let close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length) + if (close === -1) { + throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`) + } + // The last two braces of a consecutive `}` run terminate the placeholder, + // so a pattern may end with a brace quantifier like `[0-9a-f]{4}`. + while (text[close + FROM_REQUEST_CLOSE.length] === '}') close += 1 + const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close) + result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus) + cursor = close + FROM_REQUEST_CLOSE.length + } +} + +/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */ +function substituteValue(value: unknown, corpus: string): unknown { + if (typeof value === 'string') { + return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value + } + if (Array.isArray(value)) return value.map(item => substituteValue(item, corpus)) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteValue(item, corpus)])) + } + return value +} + +/** + * Resolve every `{{fromRequest:}}` placeholder in one scripted entry + * against the live request. The corpus is every string leaf of the request + * messages joined by newlines; the pattern's LAST corpus match wins and its + * first capture group (or, without one, the whole match) substitutes in place. + * Scenario sidecars use this to script arguments no static file can know, + * such as a randomly minted goal id the model must echo back. A pattern that + * matches nothing, an invalid pattern, and an unterminated placeholder each + * fail loud. The last two braces of a consecutive `}` run terminate the + * placeholder, so a pattern may end with a brace quantifier but cannot + * contain `}}` followed by further pattern content. Derived entries pass + * through the same resolution as sidecar entries. + * @param entry - the scripted entry about to replay. + * @param messages - the live request messages searched by the placeholders. + * @returns the entry itself when no placeholder appears, else a resolved deep copy. + */ +export function resolveScriptedEntry(entry: ReplayEntry, messages: GenerateOptions['messages']): ReplayEntry { + if (!JSON.stringify(entry).includes(FROM_REQUEST_OPEN)) return entry + const leaves: string[] = [] + collectStrings(messages, leaves) + return substituteValue(entry, leaves.join('\n')) as ReplayEntry +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -583,7 +673,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHand + `but its script has only ${boundState.entries.length}; re-record the scenario`, ) } - yield* replayEntry(entry, options.signal, paceMs) + yield* replayEntry(resolveScriptedEntry(entry, options.messages), options.signal, paceMs) })() } const providers = config.providers ?? [] diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index d693c0571b..8113dbc922 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, type SessionScript, @@ -17,6 +17,7 @@ import { name, parseSessionHeader, parseSessionLog, + resolveScriptedEntry, } from '../src/index.ts' /** @@ -310,6 +311,80 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) }) + describe('{{fromRequest:...}} substitution', () => { + const requestMessages = [createUserMessage({ + content: [{ type: 'text' as const, text: 'stale {"goal":{"id":"goal-old"}} then {"goal":{"id":"goal-42ab"}}' }], + source: { kind: 'user' as const }, + })] + + function scriptedCall(argumentsDelta: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'update_goal', argumentsDelta }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'update_goal', arguments: argumentsDelta } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] + } + + async function streamScripted(argumentsDelta: string): Promise { + writeLog(TEXT_CHUNKS) + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify([{ kind: 'chunks', chunks: scriptedCall(argumentsDelta) }]), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file, overrideFile }) + return drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: requestMessages })) + } + + it('resolves the capture group from the LAST request match in every scripted string field', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:"id":"(goal-[^"]+)"}}","revision":1}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab","revision":1}' }) + const end = streamed.find(chunk => chunk.type === 'block-end') + expect(end).toMatchObject({ block: { arguments: '{"goal_id":"goal-42ab","revision":1}' } }) + }) + + it('substitutes the whole match when the pattern has no capture group', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]+}}"}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + + it('keeps a trailing brace quantifier inside the pattern (terminator is the run tail)', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]{4}}}"}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + + it('fails loud when a placeholder matches nothing in the request', async () => { + await expect(streamScripted('{"goal_id":"{{fromRequest:task-[0-9]+}}"}')) + .rejects.toThrow(/fromRequest.*matched nothing/) + }) + + it('fails loud on an invalid placeholder pattern', async () => { + await expect(streamScripted('{"goal_id":"{{fromRequest:(goal-}}"}')) + .rejects.toThrow(/fromRequest.*invalid pattern/) + }) + + it('fails loud on an unterminated placeholder', () => { + const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-1"}') } + expect(() => resolveScriptedEntry(entry, requestMessages)).toThrow(/fromRequest placeholder is unterminated/) + }) + + it('returns the exact same entry when no placeholder appears', () => { + const entry: ReplayEntry = { kind: 'chunks', chunks: TEXT_CHUNKS } + expect(resolveScriptedEntry(entry, requestMessages)).toBe(entry) + }) + + it('skips non-string request leaves when building the corpus', () => { + const messages = requestMessages.map(message => ({ ...message, seq: 7 })) as unknown as GenerateOptions['messages'] + const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-42[a-z]+}}"}') } + const resolved = resolveScriptedEntry(entry, messages) + if (resolved.kind !== 'chunks') throw new Error('expected chunks entry') + expect(resolved.chunks[1]).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + }) + it('registers a replay-only provider catalog when configured', async () => { writeLog(TEXT_CHUNKS) const ctx = new Context() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d45f42458..0b634898ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1879,9 +1879,15 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation