From eff7b758b1a0781acf66d176c108ac0e49f42544 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 3 Aug 2026 21:28:38 +0800 Subject: [PATCH 001/179] fix(workspace-context): restore baseline after compaction --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 16 +- .../2026-06-24-workspace-context.zh.md | 16 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 3 +- .../fixtures/workspace-context-compaction.ts | 26 +++ .../snapshots/workspace-context/session.jsonl | 54 +++--- .../workspace-context.cordis.snapshot.yml | 2 + examples/package.json | 1 + knip.json | 1 + packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 2 +- packages/context/README.zh.md | 2 +- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 12 +- .../context/workspace-context/README.zh.md | 14 +- .../context/workspace-context/package.json | 1 + .../context/workspace-context/src/index.ts | 65 ++++++- .../tests/workspace-context.spec.ts | 159 +++++++++++++++++- .../context/workspace-context/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 24 files changed, 325 insertions(+), 77 deletions(-) create mode 100644 examples/acp-agent/tests/fixtures/workspace-context-compaction.ts diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 073aa9fa4b..0cbf595b7a 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-24-workspace-context.md -2026-06-24-workspace-context.md: 8baced0143abb38ff34d16a072761ec016a53d6e -2026-06-24-workspace-context.zh.md: 392d57f344b97c1816f691fef75440f815bccb50 +2026-06-24-workspace-context.md: b224eedb03cd1e48842c883637b2097ee83fd4b4 +2026-06-24-workspace-context.zh.md: f6c410b9467091b85a3be43c69dd3aecae5ea51a diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 8baced0143..b224eedb03 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -10,11 +10,11 @@ Repository guidance such as `AGENTS.md` belongs in a coding session's effective Neighboring products establish useful conventions but differ in details. Codex treats `AGENTS.md` as native, Claude Code uses `CLAUDE.md` and familiar system-reminder-style user context, and opencode supports both names with one winner per directory plus lazy nested discovery. The harness needs cross-tool compatibility without loading duplicate or contradictory files from the same scope. -The lifecycle has two distinct classes of content. The initial applicable chain is injected once before the first request. Nested files, edits, candidate switches, and removals happen later and join the same durable append-only history. +The lifecycle has two distinct classes of content. The applicable baseline chain is injected before the first request and restored before the first request after a surface replacement shadows it. Nested files, edits, candidate switches, and removals happen later and join the same durable append-only history. ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/step`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/step`, `system-prompt/assemble`, `tools/post-execute`, and the optional `ctx.fs` capability. The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The step signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. @@ -34,6 +34,8 @@ The injection becomes a durable `user/message` with a typed `workspace-instructi A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. +Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, then checks the final visible surface. When a prior typed baseline exists but none remains visible, it recomposes and injects the current chain before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker prevents duplicate assembly before outbox drain and clears when a step or turn closes without a durable baseline, so a cancelled delivery remains eligible for the next request. + The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. The final rendering boundary escapes a literal `` anywhere in instruction content or model-visible path, scope, and budget metadata before byte accounting completes. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). ### Dynamic Discovery And Refresh @@ -52,11 +54,11 @@ Every workspace context event stores versioned metadata with `{ action, scope, p At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. -An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. +An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes a dynamic instruction event from the visible surface, that state no longer suppresses a later tool-triggered load; if it removes the baseline, prompt assembly restores the complete current chain before the next request. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. -The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. +The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Prompt assembly recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request; a queued baseline discarded with its step can be prepared again. Later successful filesystem touches can append edits or removals as dynamic messages. It never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed or post-replacement baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the next request. -There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. +There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch, post-replacement prompt assembly, or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. ### Byte Budget And Bounded Reads @@ -68,7 +70,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc **Use a global `ctx.systemPrompt.section()`.** Rejected because one Cordis context can host sessions with different cwd values, while repository-owned text is lower-authority context rather than top-authority provider system content. -**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; dynamic append-only messages handle changes and compaction re-arming. +**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; prompt assembly performs the narrow post-replacement recovery, and dynamic append-only messages handle later changes. **Load both `AGENTS.md` and `CLAUDE.md` in one directory.** Rejected because repositories in transition commonly duplicate guidance across both files. Ordered candidates make precedence explicit and configurable. @@ -82,7 +84,7 @@ Workspace guidance is isolated per session and shared by the demo front doors, W Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). -The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed files are noticed on the next successful structured touch or resume. This keeps the design deterministic and provider-neutral. +The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed baseline files are also noticed when a surface replacement or resume triggers recomposition. This keeps the design deterministic and provider-neutral. ## Deferred diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 392d57f344..f6c410b946 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -10,11 +10,11 @@ Status: implemented 相邻产品形成了值得借鉴的约定,但具体做法各不相同。Codex 原生使用 `AGENTS.md`;Claude Code 使用 `CLAUDE.md`,并采用熟悉的 system-reminder 风格用户上下文;opencode 同时支持这两个名称,每个目录只选一个胜出者,并延迟发现嵌套文件。harness 需要跨工具兼容,同时避免从同一作用域加载重复或互相矛盾的文件。 -生命周期中有两类截然不同的内容。初始适用文件链在第一次请求前一次性注入。嵌套文件、编辑、候选项切换和移除发生在其后,进入同一份持久的仅追加历史。 +生命周期中有两类截然不同的内容。适用的基线文件链会在第一次请求前注入,并在表层替换将其遮蔽后的第一次请求前恢复。嵌套文件、编辑、候选项切换和移除发生在其后,进入同一份持久的仅追加历史。 ## 决策 -该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/step`、`system-prompt/assemble`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。步骤信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 @@ -34,6 +34,8 @@ Status: implemented 恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 +在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,再检查最终可见表层。如果此前存在带类型的基线、但已无基线可见,它会在 loop 排空 outbox 并对派生请求历史创建快照之前,重新组合并注入当前文件链。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记会在 outbox 排空前避免重复组装,并在步骤或轮次关闭且未产生持久基线时清除,因此已取消的投递仍可在下一个请求中重试。 + 基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。最终渲染边界会在完成字节核算前,转义指令内容或模型可见的路径、scope 与预算元数据中出现的字面量 ``。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 ### 动态发现与刷新 @@ -52,11 +54,11 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除动态指令事件,该状态不再抑制之后由工具触发的加载;如果移除的是基线,提示词组装会在下一个请求前恢复完整的当前指令链。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 -只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 +只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它;随其步骤一起被丢弃的已排队基线可以再次准备。之后成功的文件系统触碰仍可把编辑或移除作为动态消息追加。它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。在恢复或替换后准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在下一个请求前追加更新。 -系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 +系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰、替换后的提示词组装或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 ### 字节预算与有界读取 @@ -68,7 +70,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, **使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 -**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;动态仅追加消息负责处理变更和压缩后的重新启用。 +**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;提示词组装负责狭窄的替换后恢复,动态仅追加消息则处理之后的变更。 **在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。 @@ -82,7 +84,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 -系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰或恢复时被发现。这使设计保持确定性并且与提供方无关。 +系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;表层替换或恢复触发重新组合时,也会发现外部变更的基线文件。这使设计保持确定性并且与提供方无关。 ## 延后事项 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index bded8b85c8..8a8fa2efa2 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: b11ab9bc3060aea668d142139e0a25f491a777d1 -architecture.zh.md: 5eb1cab7e453dc0423cbb42348e918de798f1f9b +architecture.md: cbd118a1259a6cc681ec52443459b021b62eee40 +architecture.zh.md: 707d56f374fd1dc689ad449090e6e1a1b9f7da4d diff --git a/docs/architecture.md b/docs/architecture.md index b11ab9bc30..cbd118a125 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,7 +169,7 @@ A swappable capability usually has **interface / implementation / consumer** lay Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` injects baseline at the first `agent/step`, restores a compacted baseline during `system-prompt/assemble` before the next request snapshot, and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 5eb1cab7e4..707d56f374 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -169,7 +169,7 @@ idle inject: 例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 -`dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 +`dsh-workspace-context` 在第一次 `agent/step` 注入基线,在下一次请求创建快照前于 `system-prompt/assemble` 期间恢复因压缩而被遮蔽的基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 ### 组合包与应用 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b59b8733d5..3938d256f1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`workspace-context`](../packages/context/workspace-context) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 8419646dca..9c5e32b3ec 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -846,6 +846,7 @@ flowchart TD pkg_workspace_context --> pkg_llm pkg_workspace_context --> pkg_paths pkg_workspace_context --> pkg_session + pkg_workspace_context --> pkg_system_prompt pkg_workspace_context --> pkg_tools pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_invariants @@ -1222,7 +1223,7 @@ flowchart TD | [`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) | | [`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) | +| [`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), [`system-prompt`](../packages/core/system-prompt), [`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) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | diff --git a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts new file mode 100644 index 0000000000..465b16acc7 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts @@ -0,0 +1,26 @@ +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-agent' +import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' +import { createUserMessage } from '@deepseek-ai/dsh-llm' + +export const name = 'workspace-context-compaction' + +/** Replace the visible workspace baseline before the snapshot's second step. */ +export function apply(ctx: Context): void { + ctx.on('agent/step', (agent, turn, step) => { + if (turn !== 1 || step !== 2) return + const baseline = agent.session.surface.nodes + .map(seq => agent.session.events[seq]) + .find(event => event?.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline === true) + if (baseline === undefined) throw new Error('workspace baseline missing before snapshot compaction') + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Earlier context was compacted for this snapshot.' }], + source: COMPACT_CHECKPOINT_SOURCE, + }), { + surfaceOp: { op: 'replace', start: baseline.seq, end: baseline.seq }, + sourceEventSeqs: [baseline.seq], + }) + }) +} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 0a568d3460..41faaef25f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,9 +1,9 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7cb62d32-ef8e-4d45-9b5e-d2a1fbdbabbd"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"950c77c7-6a48-43aa-8e72-b6068d4e876b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903339799,"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":"77cda160-cca0-495b-b283-ac39eac5da7d"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"95aaf126-946e-4ada-985a-943b490b6f2f"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -12,28 +12,30 @@ {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785464650866,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":12,"time":1785487608779,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785487608779,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6908b415-6aca-462d-9d91-0b27a73ba08c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785487608779,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d0d49aa4-74cf-4af4-9256-c8531d6f597f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785487608779,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":15,"time":1785487608790,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1144df09-c5e0-4781-8734-55acf6f2d4d0"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"ed901b04-9258-4f36-a094-c24127021158"},"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785487608790,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"b63d472c-84f3-40d3-9c3b-f291f6d466f3"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"24e6c34c-3fea-462b-8399-5d8b8c14eb9c"},"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785487608790,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":18,"time":1785487608799,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} -{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":22,"time":1785464650886,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":1785487608800,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785487608800,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"268c94fb-859c-4ed9-aa98-3a1ccfa31a6f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"tool/call","seq":25,"time":1785487608801,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} -{"type":"tool/result","seq":26,"time":1785487608810,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"228be9fc-eacf-4a2e-a475-9d4f46b2606d"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[25],"surfaceOp":"append"} -{"type":"user/message","seq":27,"time":1785487608811,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"f955bfc9-0679-478e-84d8-77e266114c44"},"surfaceOp":"append"} -{"type":"step/end","seq":28,"time":1785487608811,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":29,"time":1785487608818,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":30,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":31,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":32,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":33,"time":1785464650905,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":34,"time":1785487608819,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785487608819,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e5f5ae5d-e7fc-47e8-a1af-92fc859e3612"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785487608819,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":37,"time":1785487608819,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":18,"time":1785762637747,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"5413be2d-cb6c-490c-9fa3-64b95c20b72b"},"sourceEventSeqs":[4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"user/message","seq":19,"time":1785762637756,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"5417d355-11a7-4d9a-b724-f63acf215392"},"surfaceOp":"append"} +{"type":"step/start","seq":20,"time":1785762637756,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":22,"time":1785464650886,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} +{"type":"assistant/chunk","seq":23,"time":1785487608800,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":24,"time":1785762637757,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":25,"time":1785762637757,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":26,"time":1785762637757,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"12b61b11-c415-41c1-8e67-15b097112399"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"tool/call","seq":27,"time":1785762637757,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} +{"type":"tool/result","seq":28,"time":1785762637766,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"621ff2e6-6cb7-4465-bf57-65bfff611a82"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[27],"surfaceOp":"append"} +{"type":"user/message","seq":29,"time":1785762637767,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"b790daa3-c5f6-4954-ab0f-0362f1b47487"},"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1785762637767,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":31,"time":1785762637773,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":32,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":33,"time":1785464650905,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":34,"time":1785487608819,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":35,"time":1785762637774,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":36,"time":1785762637774,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785762637775,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"80c9c0f4-8bf0-4a39-83ed-247f8ef62881"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785762637775,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":39,"time":1785762637775,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/workspace-context.cordis.snapshot.yml b/examples/acp-agent/workspace-context.cordis.snapshot.yml index 70d726c838..6abaaa3f13 100644 --- a/examples/acp-agent/workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/workspace-context.cordis.snapshot.yml @@ -28,3 +28,5 @@ - insert: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' + - id: workspace-context-compaction + name: './tests/fixtures/workspace-context-compaction.ts' diff --git a/examples/package.json b/examples/package.json index b1a32fc381..43771a4834 100644 --- a/examples/package.json +++ b/examples/package.json @@ -20,6 +20,7 @@ "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-command-goal": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compact": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", diff --git a/knip.json b/knip.json index f1a6efef71..e73b619226 100644 --- a/knip.json +++ b/knip.json @@ -43,6 +43,7 @@ "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", + "acp-agent/tests/fixtures/workspace-context-compaction.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index 7836a2a03c..c77abeeeeb 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/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/context/README.md -README.md: fce6e21816d261171aaeaa217171580adb7c43f9 -README.zh.md: b8a4d68ca6892b51ed52479a7296513f5edcc292 +README.md: 0eb554eda5bc461cfb3b5d437209a7308f26a97a +README.zh.md: 18d36b63f8603134689a330ebfe9b2b034c64cc8 diff --git a/packages/context/README.md b/packages/context/README.md index fce6e21816..0eb554eda5 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -9,6 +9,6 @@ Product plugins that add model-visible request context without defining a tool. | `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | | `time-context/` | Durable per-step current time and elapsed-time context | (none) | | `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/step`, reads `ctx.bash`) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `system-prompt/assemble` + `tools/post-execute`) | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index b8a4d68ca6..18d36b63f8 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -9,6 +9,6 @@ | `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` | | `time-context/` | 持久化的逐步骤当前时间与已用时上下文 | (无) | | `tmux-context/` | 持久化的逐轮次上下文,记录本 agent 所在的 tmux pane/window 位置 | (监听 `agent/step`,读取 `ctx.bash`) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `system-prompt/assemble` + `tools/post-execute`) | [`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了每个 agent(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。 diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 102c991391..27e5adca83 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/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/context/workspace-context/README.md -README.md: 2669422ec1fa7a74ba329cd96ee6b7e5e6da7e9d -README.zh.md: e9fab4c6998f1193068389b41bdd7fa7d8c98dca +README.md: 8201a9bb347c27da432748576151a0f6db9f3d9f +README.zh.md: adcf50d70fe685a0cf8604405be5568fbcb409c1 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 2669422ec1..8201a9bb34 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. +The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. If a later surface replacement such as compaction shadows that baseline, `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots its next request. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. @@ -52,7 +52,7 @@ Model-visible text contains no hidden state markers. Each baseline or dynamic co An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. -The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. +The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. After a surface replacement removes it, prompt assembly recomposes the current baseline for that replacement generation and injects it before the first post-replacement request; a queued baseline that is discarded with its step remains eligible for the next request. A successful filesystem touch can still append later replacements or removals. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when prompt assembly restores a shadowed baseline, or when a resumed loop prepares its baseline. ## Configuration @@ -83,7 +83,7 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even #### What the model sees -At the first request of each loop instance, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order. +At the first request of each loop instance, and again on the first request after a surface replacement shadows it, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order. ##### Baseline instruction template @@ -103,11 +103,11 @@ Instructions from: AGENTS.md #### Token effect -The rendered baseline is appended once and remains in derived history until compaction. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens. +The rendered baseline remains in derived history until a surface replacement shadows it, then one recomposed baseline is appended before the next request. `maxBytes` bounds each complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens. #### KV Cache effect -Append-only after the existing reusable prefix. A new or resumed instance may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position. +Append-only after the existing reusable prefix. A new, resumed, or post-compaction request may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position. ### Newly discovered scope context @@ -162,7 +162,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam. -- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop prepares its baseline. +- **Refresh is event-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when prompt assembly restores a shadowed baseline, or when a resumed loop prepares its baseline. - **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration. - **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it. - **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index e9fab4c699..adcf50d70f 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -6,7 +6,7 @@ ## 生命周期 -基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。 +基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,`system-prompt/assemble` 会在 loop 对下一个请求创建快照之前,重新组合并注入当前指令链。 该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 @@ -50,9 +50,9 @@ These instructions apply to work under `packages/app`. Use them as guidance when 模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 -路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 +路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。表层替换将其移除后,提示词组装会为该替换代次重新组合当前基线,并在替换后的第一个请求前注入;随其步骤一起被丢弃的已排队基线仍可在下一个请求中重新准备。成功的文件系统 touch 仍可在之后追加替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 ## 配置 @@ -83,7 +83,7 @@ export interface Config { #### 模型看到的内容 -在每个 loop 实例的第一个请求中,模型会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。 +在每个 loop 实例的第一个请求中,以及表层替换将其遮蔽后的第一个请求中,模型都会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。 ##### 基线指令模板 @@ -103,11 +103,11 @@ Instructions from: AGENTS.md #### Token 影响 -渲染后基线只追加一次,并保留在派生历史中直到压缩。`maxBytes` 会限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。 +渲染后基线会保留在派生历史中,直到表层替换将其遮蔽;随后会在下一个请求前追加一条重新组合的基线。`maxBytes` 会限制每条完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。 #### KV Cache 影响 -仅追加,位于现有可复用前缀之后。新建或恢复的实例可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。 +仅追加,位于现有可复用前缀之后。新建实例的请求、恢复后的请求或压缩后的请求可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。 ### 新发现的 scope 上下文 @@ -162,7 +162,7 @@ The previously loaded instructions from this file no longer apply. ## 已知限制与暂缓事项 - **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。 -- **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在恢复 loop 准备基线时可见。 +- **刷新由事件驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 - **候选语义有意保持简单**:不解释小写名称、`.claude/rules/` 与 `@path` import;项目 scope 默认加载 `AGENTS.local.md`/`CLAUDE.local.md` overlay,但用户全局 `$DSH_HOME` scope 没有本地 overlay,其他自定义名称需要显式候选配置。 - **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。 - **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低优先级的工作区指引(它绝不会覆盖 system、developer 或用户直接下达的指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`。 diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 0c50b8cc17..bac4522dcc 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 52dc76070c..7d8b6d1cc6 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -1,7 +1,8 @@ /** * Workspace instruction loader for AGENTS.md-compatible files. * - * Baseline instructions enter durable context before the first request; successful fs + * Baseline instructions enter durable context before the first request and are + * restored during prompt assembly when compaction removes them. Successful fs * tool touches reconcile nested, changed, and removed instructions through * `tools/post-execute` for the next model request. Plugin lifecycle reads use * the optional `ctx.fs` provider, so providerless products mount it as a no-op. @@ -12,6 +13,7 @@ import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-system-prompt' import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, type ResolvedConfig } from './config.ts' import { loadBaselineInstructionSet } from './files.ts' @@ -44,15 +46,21 @@ export type { export { renderWorkspaceContext } from './render.ts' export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts' -function hasVisibleBaseline(agent: Agent): boolean { - return agent.session.surface.nodes.some((seq) => { - const event = agent.session.events[seq] +function hasVisibleBaseline(session: Agent['session']): boolean { + return session.surface.nodes.some((seq) => { + const event = session.events[seq] return event?.type === 'user/message' && event.data.source.kind === 'workspace-instructions' && event.data.source.baseline === true }) } +function hasBaselineHistory(agent: Agent): boolean { + return agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline === true) +} + export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = resolveConfig(config) const pendingNestedChanges = new WeakMap>() @@ -60,6 +68,10 @@ export function apply(ctx: Context, config: Config): void { const instructionVersions: InstructionVersionCache = new WeakMap() const pendingVersionUpdates = new Map() const baselineLoaded = new WeakSet() + // Settled means this generation needed no new baseline; queued covers the + // interval before an injected baseline becomes a durable surface event. + const baselineSettledGeneration = new WeakMap() + const baselineQueuedGeneration = new WeakMap() // Sessions whose lifecycle start this mount witnessed. A startup or resume // emits agent/session-start before the first step; a hot remount attaches to // an already-live session and never sees it. Resumes always re-compose the @@ -78,17 +90,29 @@ export function apply(ctx: Context, config: Config): void { ctx.on('session/event', (session, event) => { observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions) + if (event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline === true) baselineQueuedGeneration.delete(session) + if ((event.type === 'step/end' || event.type === 'turn/end') + && !hasVisibleBaseline(session)) baselineQueuedGeneration.delete(session) }) - ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { - if (baselineLoaded.has(agent.session)) return + const prepareBaseline = async ( + agent: Agent, + signal: AbortSignal | undefined, + keepVisibleBaseline: boolean, + ): Promise => { if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { baselineLoaded.add(agent.session) + baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration) + baselineQueuedGeneration.delete(agent.session) return } const fileSystem = ctx.get('fs') if (fileSystem === undefined) { baselineLoaded.add(agent.session) + baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration) + baselineQueuedGeneration.delete(agent.session) return } /* v8 ignore next -- normal agents carry an absolute session cwd. */ @@ -101,7 +125,7 @@ export function apply(ctx: Context, config: Config): void { maxSourceBytes: resolved.maxSourceBytes, instructionFileCandidates: resolved.instructionFileCandidates, localInstructionFileCandidates: resolved.localInstructionFileCandidates, - signal, + ...signal === undefined ? {} : { signal }, }, fileSystem) const baseline = baselineInstructionState(instructions?.included ?? []) baselineSessions.add(agent.session) @@ -113,15 +137,16 @@ export function apply(ctx: Context, config: Config): void { pendingNestedChanges, instructionVersions, fileSystem, - { includeBaselineScopes: false, signal }, + { includeBaselineScopes: false, ...signal === undefined ? {} : { signal } }, ) if (update !== undefined) { agent.inject(update.context) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) } - const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent) if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) { const baselineMessage = workspaceContextMessage(instructions.rendered.text) + baselineSettledGeneration.delete(agent.session) + baselineQueuedGeneration.set(agent.session, agent.session.surface.replaceGeneration) agent.inject(createUserMessage({ content: baselineMessage.content, source: { @@ -130,8 +155,30 @@ export function apply(ctx: Context, config: Config): void { changes: [...baseline.changes.values()], }, })) + } else { + baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration) + baselineQueuedGeneration.delete(agent.session) } baselineLoaded.add(agent.session) + } + + ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { + if (baselineLoaded.has(agent.session)) return + const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent.session) + await prepareBaseline(agent, signal, keepVisibleBaseline) + }) + + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + const assembled = await next() + const agent = context.agent + if (agent === undefined + || !baselineLoaded.has(agent.session) + || hasVisibleBaseline(agent.session) + || baselineSettledGeneration.get(agent.session) === agent.session.surface.replaceGeneration + || baselineQueuedGeneration.get(agent.session) === agent.session.surface.replaceGeneration + || !hasBaselineHistory(agent)) return assembled + await prepareBaseline(agent, context.signal, false) + return assembled }) ctx.on('tools/post-execute', async ( diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 158d96f24d..fb099fbc85 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -1086,6 +1086,131 @@ describe('workspace context request injection', () => { } }) + it('restores a compacted baseline during prompt assembly before another filesystem touch', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + const baseline = baselineEvents(agent)[0] + expect(baseline).toBeDefined() + + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq }, + sourceEventSeqs: [baseline!.seq], + }) + + await ctx.systemPrompt.assemble(assembleContextFor(agent)) + + expect(baselineEvents(agent)).toHaveLength(2) + expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('repo rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('retries a re-injected baseline when its queued step closes before the message becomes durable', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + const baseline = baselineEvents(agent)[0] + expect(baseline).toBeDefined() + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq }, + sourceEventSeqs: [baseline!.seq], + }) + const queued: UserMessage[] = [] + const queuedAgent: Agent = { + ...agent, + inject(input) { queued.push(input) }, + } + + await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal)) + await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal)) + expect(queued).toHaveLength(1) + + ctx.emit('session/event', agent.session, { + type: 'step/end', seq: 999, time: 0, data: { turn: 1, step: 1 }, + }) + await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal)) + + expect(queued).toHaveLength(2) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('includes a re-injected baseline in the first real request after a between-step replacement', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'first post-compaction request rule') + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.on('agent/step', (subject, turn) => { + if (turn !== 2) return + const baseline = baselineEvents(subject).find(event => subject.session.surface.nodes.includes(event.seq)) + if (baseline === undefined) throw new Error('first turn did not retain its workspace baseline') + subject.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: baseline.seq, end: baseline.seq }, + sourceEventSeqs: [baseline.seq], + }) + }) + const agent = ctx.agentLoop.create( + SessionId('workspace-context-post-compact'), + { provider: 'mock', model: 'mock' }, + { cwd: root }, + ) + + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } })) + await agent.whenIdle() + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } })) + await agent.whenIdle() + + expect(adapter.requests).toHaveLength(2) + expect(adapter.requests[1]?.messages.map(message => blocksText(message.content)).join('\n')) + .toContain('first post-compaction request rule') + expect(baselineEvents(agent)).toHaveLength(2) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('recomposes the baseline from current files when a resumed session edited it offline', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1675,6 +1800,38 @@ describe('workspace context request injection', () => { } }) + it('cleans up its prompt-assembly listener when the plugin fiber is disposed', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + const baseline = baselineEvents(agent)[0] + expect(baseline).toBeDefined() + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq }, + sourceEventSeqs: [baseline!.seq], + }) + await fiber.dispose() + + await ctx.systemPrompt.assemble(assembleContextFor(agent, testToolSignal)) + + expect(baselineEvents(agent)).toHaveLength(1) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('does not inject anything when maxBytes is zero', async () => { const root = await tempRepo() const home = await tempRepo() diff --git a/packages/context/workspace-context/tsconfig.json b/packages/context/workspace-context/tsconfig.json index b5aca1dfc8..838059d342 100644 --- a/packages/context/workspace-context/tsconfig.json +++ b/packages/context/workspace-context/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../core/system-prompt" + }, { "path": "../../core/tools" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e27760b0d..daf02246fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -608,6 +608,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:* version: link:../packages/ui/commands + '@deepseek-ai/dsh-compact': + specifier: workspace:* + version: link:../packages/compact/compact '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic From 19be29cd0bc21d8e58a41c9d150c173684078549 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 3 Aug 2026 22:05:17 +0800 Subject: [PATCH 002/179] fix(workspace-context): guard baseline restoration --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 4 +- .../2026-06-24-workspace-context.zh.md | 4 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 32 ++--- .../system-prompt.i18n.yaml | 4 +- docs/core-data-structures/system-prompt.md | 2 +- docs/core-data-structures/system-prompt.zh.md | 2 +- docs/event-producer-consumer.md | 32 ++--- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 6 +- .../context/workspace-context/README.zh.md | 6 +- .../context/workspace-context/src/index.ts | 46 +++--- .../tests/workspace-context.spec.ts | 132 +++++++++++++++--- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 4 +- .../agent-loop/tests/agent-initiator.spec.ts | 5 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/dispatch.ts | 12 ++ packages/core/agent/src/index.ts | 2 +- packages/core/agent/src/types.ts | 2 + packages/core/agent/tests/agent.spec.ts | 19 +++ packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 2 +- packages/core/system-prompt/README.zh.md | 2 +- 31 files changed, 249 insertions(+), 105 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 0cbf595b7a..359a4aac1c 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-24-workspace-context.md -2026-06-24-workspace-context.md: b224eedb03cd1e48842c883637b2097ee83fd4b4 -2026-06-24-workspace-context.zh.md: f6c410b9467091b85a3be43c69dd3aecae5ea51a +2026-06-24-workspace-context.md: 19b227c56c6c50f802f1d9cf32ca6f21b3a24180 +2026-06-24-workspace-context.zh.md: 86137db7296cce99eeb06e5cc023f20ded0f77c4 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index b224eedb03..19b227c56c 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -34,7 +34,7 @@ The injection becomes a durable `user/message` with a typed `workspace-instructi A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. -Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, then checks the final visible surface. When a prior typed baseline exists but none remains visible, it recomposes and injects the current chain before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker prevents duplicate assembly before outbox drain and clears when a step or turn closes without a durable baseline, so a cancelled delivery remains eligible for the next request. +Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, but restores only for an assembly explicitly marked for the loop's next model request; diagnostic assemblies such as TUI `/status` remain read-only. When a prior typed baseline exists but none remains visible, the listener recomposes the current chain, rechecks cancellation and the current surface generation after every asynchronous probe, and injects before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker plus the synchronous commit-time recheck lets concurrent preparations scan without queuing duplicate baselines. The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. The final rendering boundary escapes a literal `` anywhere in instruction content or model-visible path, scope, and budget metadata before byte accounting completes. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). @@ -56,7 +56,7 @@ At reconciliation time the plugin scans workspace-sourced `user/message` events An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes a dynamic instruction event from the visible surface, that state no longer suppresses a later tool-triggered load; if it removes the baseline, prompt assembly restores the complete current chain before the next request. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. -The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Prompt assembly recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request; a queued baseline discarded with its step can be prepared again. Later successful filesystem touches can append edits or removals as dynamic messages. It never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed or post-replacement baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the next request. +The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Model-request prompt assembly recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request. It rechecks the caller's signal before injection, so an aborted preparation publishes no pending baseline; a queued marker remains until the corresponding durable event confirms delivery. Later successful filesystem touches can append edits or removals as dynamic messages. The plugin never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed or post-replacement baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the next request. There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch, post-replacement prompt assembly, or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index f6c410b946..86137db729 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -34,7 +34,7 @@ Status: implemented 恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 -在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,再检查最终可见表层。如果此前存在带类型的基线、但已无基线可见,它会在 loop 排空 outbox 并对派生请求历史创建快照之前,重新组合并注入当前文件链。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记会在 outbox 排空前避免重复组装,并在步骤或轮次关闭且未产生持久基线时清除,因此已取消的投递仍可在下一个请求中重试。 +在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,但只有当组装被明确标记为供 loop 的下一个模型请求使用时才恢复;TUI `/status` 等诊断组装保持只读。如果此前存在带类型的基线、但已无基线可见,该监听器会重新组合当前文件链,在每次异步探测后重新检查取消状态和当前表层代次,并在 loop 排空 outbox 和对派生请求历史创建快照之前注入。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记加上提交时同步复查,使并发准备可以扫描而不会排入重复基线。 基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。最终渲染边界会在完成字节核算前,转义指令内容或模型可见的路径、scope 与预算元数据中出现的字面量 ``。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 @@ -56,7 +56,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除动态指令事件,该状态不再抑制之后由工具触发的加载;如果移除的是基线,提示词组装会在下一个请求前恢复完整的当前指令链。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 -只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它;随其步骤一起被丢弃的已排队基线可以再次准备。之后成功的文件系统触碰仍可把编辑或移除作为动态消息追加。它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。在恢复或替换后准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在下一个请求前追加更新。 +只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。面向模型请求的提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它。它会在注入前重新检查调用方的 signal,因此已中止的准备不会发布待处理基线;排队标记会保留,直到相应的持久事件确认投递。之后成功的文件系统触碰仍可把编辑或移除作为动态消息追加。插件绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。在恢复或替换后准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在下一个请求前追加更新。 系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰、替换后的提示词组装或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8a8fa2efa2..2c1022ccbf 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: cbd118a1259a6cc681ec52443459b021b62eee40 -architecture.zh.md: 707d56f374fd1dc689ad449090e6e1a1b9f7da4d +architecture.md: f9a0856d270c239d45ecca0965787a443ce184ae +architecture.zh.md: 9b5b1877f7a5afa6924e5c13674804c4eb889699 diff --git a/docs/architecture.md b/docs/architecture.md index cbd118a125..f9a0856d27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,7 +169,7 @@ A swappable capability usually has **interface / implementation / consumer** lay Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` injects baseline at the first `agent/step`, restores a compacted baseline during `system-prompt/assemble` before the next request snapshot, and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` injects baseline at the first `agent/step`, restores a compacted baseline during the loop's model-request `system-prompt/assemble` before the request snapshot, and appends `ctx.fs`-discovered changes through `tools/post-execute`; inspection-only assemblies stay read-only. Its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 707d56f374..9b5b1877f7 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -169,7 +169,7 @@ idle inject: 例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 -`dsh-workspace-context` 在第一次 `agent/step` 注入基线,在下一次请求创建快照前于 `system-prompt/assemble` 期间恢复因压缩而被遮蔽的基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 +`dsh-workspace-context` 在第一次 `agent/step` 注入基线,在请求创建快照前于 loop 面向模型请求的 `system-prompt/assemble` 期间恢复因压缩而被遮蔽的基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;仅检查组装保持只读。其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 ### 组合包与应用 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 8f4385938c..7ece38cbc0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:467`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:469`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:380`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:406`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:408`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:454`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:456`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:393`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:442`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/system-prompt.i18n.yaml b/docs/core-data-structures/system-prompt.i18n.yaml index 2984c73425..9b29344827 100644 --- a/docs/core-data-structures/system-prompt.i18n.yaml +++ b/docs/core-data-structures/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/system-prompt.md -system-prompt.md: 5abb8f46c13045c7d37bbe12ecf6c3744ee063b5 -system-prompt.zh.md: 1088b20ba4289ad5912a193eead39d069c1a6e17 +system-prompt.md: d585dd2604e83a808beefd8fb22685ab6b88878a +system-prompt.zh.md: ade05e151cb70af98e47c18cf63dac89fecf35f2 diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md index 5abb8f46c1..d585dd2604 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -8,7 +8,7 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system- ## Assembly context -`AssembleContext` identifies the scope layer one assembly resolves and may carry the explicit control signal for that request. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent, signal)` sets the explicit fields together. A bare assembly has neither scope nor signal. +`AssembleContext` identifies the scope layer one assembly resolves and may carry the explicit control signal for that request. It is merge-extensible: `dsh-agent` adds the optional live `agent` field and `modelRequest?: true` marker. `assembleContextFor(agent, signal)` builds an agent-scoped inspection context; `assembleRequestContextFor(agent, signal)` marks a result that the caller will materialize into the next model request. A bare assembly has neither scope nor signal. ```ts type-equiv /** Merge-extensible context for one prompt assembly. */ diff --git a/docs/core-data-structures/system-prompt.zh.md b/docs/core-data-structures/system-prompt.zh.md index 1088b20ba4..ade05e151c 100644 --- a/docs/core-data-structures/system-prompt.zh.md +++ b/docs/core-data-structures/system-prompt.zh.md @@ -8,7 +8,7 @@ ## 组装上下文 -`AssembleContext` 标识一次组装所解析的作用域 layer,并可携带该请求的显式控制 signal。它可合并扩展:`dsh-agent` 添加可选的 live `agent` 字段,`assembleContextFor(agent, signal)` 则一起设置这些显式字段。裸组装既没有 scope,也没有 signal。 +`AssembleContext` 标识一次组装所解析的作用域 layer,并可携带该请求的显式控制 signal。它可合并扩展:`dsh-agent` 添加可选的 live `agent` 字段和 `modelRequest?: true` 标记。`assembleContextFor(agent, signal)` 构建带 agent 作用域的检查上下文;`assembleRequestContextFor(agent, signal)` 将结果标记为调用方会把它物化为下一个模型请求。裸组装既没有 scope,也没有 signal。 ```ts type-equiv /** Merge-extensible context for one prompt assembly. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3938d256f1..aefb1baed8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:467`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:380`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:454`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:393`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:469`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 27e5adca83..9dd438c1ba 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/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/context/workspace-context/README.md -README.md: 8201a9bb347c27da432748576151a0f6db9f3d9f -README.zh.md: adcf50d70fe685a0cf8604405be5568fbcb409c1 +README.md: c79f208705c827c609a4b482f0057a18ac0d8fc0 +README.zh.md: aacdcc9b2b12aca8394fb87099af0c382677bef1 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 8201a9bb34..c79f208705 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. If a later surface replacement such as compaction shadows that baseline, `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots its next request. +The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. If a later surface replacement such as compaction shadows that baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies such as TUI `/status` do not mutate the session. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. @@ -52,7 +52,7 @@ Model-visible text contains no hidden state markers. Each baseline or dynamic co An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. -The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. After a surface replacement removes it, prompt assembly recomposes the current baseline for that replacement generation and injects it before the first post-replacement request; a queued baseline that is discarded with its step remains eligible for the next request. A successful filesystem touch can still append later replacements or removals. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when prompt assembly restores a shadowed baseline, or when a resumed loop prepares its baseline. +The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. After a surface replacement removes it, model-request prompt assembly recomposes the current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. A successful filesystem touch can still append later replacements or removals. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, or when a resumed loop prepares its baseline. ## Configuration @@ -162,7 +162,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam. -- **Refresh is event-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when prompt assembly restores a shadowed baseline, or when a resumed loop prepares its baseline. +- **Refresh is event-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when model-request prompt assembly restores a shadowed baseline, or when a resumed loop prepares its baseline. - **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration. - **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it. - **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index adcf50d70f..aacdcc9b2b 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -6,7 +6,7 @@ ## 生命周期 -基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,`system-prompt/assemble` 会在 loop 对下一个请求创建快照之前,重新组合并注入当前指令链。 +基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;TUI `/status` 等仅检查组装不会改变会话。 该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 @@ -52,7 +52,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when 路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。表层替换将其移除后,提示词组装会为该替换代次重新组合当前基线,并在替换后的第一个请求前注入;随其步骤一起被丢弃的已排队基线仍可在下一个请求中重新准备。成功的文件系统 touch 仍可在之后追加替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。表层替换将其移除后,面向模型请求的提示词组装会重新组合当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。成功的文件系统 touch 仍可在之后追加替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 ## 配置 @@ -162,7 +162,7 @@ The previously loaded instructions from this file no longer apply. ## 已知限制与暂缓事项 - **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。 -- **刷新由事件驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 +- **刷新由事件驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read`、`write` 或 `edit` 时可见,也会在面向模型请求的提示词组装恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 - **候选语义有意保持简单**:不解释小写名称、`.claude/rules/` 与 `@path` import;项目 scope 默认加载 `AGENTS.local.md`/`CLAUDE.local.md` overlay,但用户全局 `$DSH_HOME` scope 没有本地 overlay,其他自定义名称需要显式候选配置。 - **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。 - **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低优先级的工作区指引(它绝不会覆盖 system、developer 或用户直接下达的指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`。 diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 7d8b6d1cc6..7ca9189a4f 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -2,7 +2,7 @@ * Workspace instruction loader for AGENTS.md-compatible files. * * Baseline instructions enter durable context before the first request and are - * restored during prompt assembly when compaction removes them. Successful fs + * restored during model-request prompt assembly when compaction removes them. Successful fs * tool touches reconcile nested, changed, and removed instructions through * `tools/post-execute` for the next model request. Plugin lifecycle reads use * the optional `ctx.fs` provider, so providerless products mount it as a no-op. @@ -55,8 +55,8 @@ function hasVisibleBaseline(session: Agent['session']): boolean { }) } -function hasBaselineHistory(agent: Agent): boolean { - return agent.session.events.some(event => event.type === 'user/message' +function hasBaselineHistory(session: Agent['session']): boolean { + return session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'workspace-instructions' && event.data.source.baseline === true) } @@ -93,14 +93,13 @@ export function apply(ctx: Context, config: Config): void { if (event.type === 'user/message' && event.data.source.kind === 'workspace-instructions' && event.data.source.baseline === true) baselineQueuedGeneration.delete(session) - if ((event.type === 'step/end' || event.type === 'turn/end') - && !hasVisibleBaseline(session)) baselineQueuedGeneration.delete(session) }) const prepareBaseline = async ( agent: Agent, signal: AbortSignal | undefined, keepVisibleBaseline: boolean, + deduplicateRestore = false, ): Promise => { if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { baselineLoaded.add(agent.session) @@ -139,6 +138,13 @@ export function apply(ctx: Context, config: Config): void { fileSystem, { includeBaselineScopes: false, ...signal === undefined ? {} : { signal } }, ) + signal?.throwIfAborted() + const generation = agent.session.surface.replaceGeneration + if (deduplicateRestore && ( + hasVisibleBaseline(agent.session) + || baselineSettledGeneration.get(agent.session) === generation + || baselineQueuedGeneration.get(agent.session) === generation + )) return if (update !== undefined) { agent.inject(update.context) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) @@ -146,15 +152,20 @@ export function apply(ctx: Context, config: Config): void { if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) { const baselineMessage = workspaceContextMessage(instructions.rendered.text) baselineSettledGeneration.delete(agent.session) - baselineQueuedGeneration.set(agent.session, agent.session.surface.replaceGeneration) - agent.inject(createUserMessage({ - content: baselineMessage.content, - source: { - kind: 'workspace-instructions', - baseline: true, - changes: [...baseline.changes.values()], - }, - })) + baselineQueuedGeneration.set(agent.session, generation) + try { + agent.inject(createUserMessage({ + content: baselineMessage.content, + source: { + kind: 'workspace-instructions', + baseline: true, + changes: [...baseline.changes.values()], + }, + })) + } catch (error: unknown) { + baselineQueuedGeneration.delete(agent.session) + throw error + } } else { baselineSettledGeneration.set(agent.session, agent.session.surface.replaceGeneration) baselineQueuedGeneration.delete(agent.session) @@ -171,13 +182,14 @@ export function apply(ctx: Context, config: Config): void { ctx.on('system-prompt/assemble', async (_assembly, context, next) => { const assembled = await next() const agent = context.agent - if (agent === undefined + if (context.modelRequest !== true + || agent === undefined || !baselineLoaded.has(agent.session) || hasVisibleBaseline(agent.session) || baselineSettledGeneration.get(agent.session) === agent.session.surface.replaceGeneration || baselineQueuedGeneration.get(agent.session) === agent.session.surface.replaceGeneration - || !hasBaselineHistory(agent)) return assembled - await prepareBaseline(agent, context.signal, false) + || !hasBaselineHistory(agent.session)) return assembled + await prepareBaseline(agent, context.signal, false, true) return assembled }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index fb099fbc85..4d46e1393e 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor, assembleRequestContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -155,6 +155,26 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } +class OverlappingReadFileSystem extends RecordingFileSystem { + readonly paired = Promise.withResolvers() + readonly release = Promise.withResolvers() + private armed = false + private started = 0 + + arm(): void { + this.armed = true + } + + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { + if (this.armed) { + this.started += 1 + if (this.started === 2) this.paired.resolve(undefined) + await this.release.promise + } + return super.streamText(target, signal) + } +} + async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) return ctx.plugin(workspaceContext, config) @@ -1108,7 +1128,7 @@ describe('workspace context request injection', () => { sourceEventSeqs: [baseline!.seq], }) - await ctx.systemPrompt.assemble(assembleContextFor(agent)) + await ctx.systemPrompt.assemble(assembleRequestContextFor(agent)) expect(baselineEvents(agent)).toHaveLength(2) expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('repo rule') @@ -1118,7 +1138,7 @@ describe('workspace context request injection', () => { } }) - it('retries a re-injected baseline when its queued step closes before the message becomes durable', async () => { + it('does not restore a compacted baseline for an inspection-only assembly', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1138,28 +1158,104 @@ describe('workspace context request injection', () => { surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq }, sourceEventSeqs: [baseline!.seq], }) - const queued: UserMessage[] = [] - const queuedAgent: Agent = { - ...agent, - inject(input) { queued.push(input) }, - } + await ctx.systemPrompt.assemble(assembleContextFor(agent, testToolSignal)) - await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal)) - await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal)) - expect(queued).toHaveLength(1) - - ctx.emit('session/event', agent.session, { - type: 'step/end', seq: 999, time: 0, data: { turn: 1, step: 1 }, - }) - await ctx.systemPrompt.assemble(assembleContextFor(queuedAgent, testToolSignal)) - - expect(queued).toHaveLength(2) + expect(baselineEvents(agent)).toHaveLength(1) + expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('compacted summary') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } }) + it('queues one baseline when two request assemblies finish preparation concurrently', async () => { + const root = resolve('/virtual/concurrent-assembly-repo') + const home = resolve('/virtual/concurrent-assembly-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(OverlappingReadFileSystem) + const fs = ctx.fs as OverlappingReadFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + const baseline = baselineEvents(agent)[0] + expect(baseline).toBeDefined() + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq }, + sourceEventSeqs: [baseline!.seq], + }) + const queued: UserMessage[] = [] + const queuedAgent: Agent = { + ...agent, + acceptsNextStep: true, + inject(input) { queued.push(input) }, + } + fs.arm() + + const first = ctx.systemPrompt.assemble(assembleRequestContextFor(queuedAgent, testToolSignal)) + const second = ctx.systemPrompt.assemble(assembleRequestContextFor(queuedAgent, testToolSignal)) + await fs.paired.promise + fs.release.resolve(undefined) + await Promise.all([first, second]) + + expect(queued).toHaveLength(1) + expect(blocksText(queued[0]?.content)).toContain('repo rule') + } finally { + await ctx.fiber.dispose() + } + }) + + it('retries restoration after synchronous baseline injection failure', async () => { + const root = resolve('/virtual/injection-failure-repo') + const home = resolve('/virtual/injection-failure-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + await composeBaselinePrefix(ctx, agent) + const baseline = baselineEvents(agent)[0] + expect(baseline).toBeDefined() + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq }, + sourceEventSeqs: [baseline!.seq], + }) + const throwingAgent: Agent = { + ...agent, + inject() { throw new Error('injection failed') }, + } + + await expect(ctx.systemPrompt.assemble( + assembleRequestContextFor(throwingAgent, testToolSignal), + )).rejects.toThrow('injection failed') + + const queued: UserMessage[] = [] + const retryingAgent: Agent = { + ...agent, + inject(input) { queued.push(input) }, + } + await ctx.systemPrompt.assemble(assembleRequestContextFor(retryingAgent, testToolSignal)) + + expect(queued).toHaveLength(1) + expect(blocksText(queued[0]?.content)).toContain('repo rule') + } finally { + await ctx.fiber.dispose() + } + }) + it('includes a re-injected baseline in the first real request after a between-step replacement', async () => { const root = await tempRepo() const home = await tempRepo() diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 81f5271097..e030d8da9c 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: 2ce85071c4b7408adb4ee05291c499ec642be114 -README.zh.md: bc78c02fc046f3bb5820f89bae5a90b26b5a8ced +README.md: 5f3da35f818569fa78d19b517e0fc7759c2bd793 +README.zh.md: 54d3409e413be40d40ccea8ca3df13b2d78ed8c0 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2ce85071c4..5f3da35f81 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -14,7 +14,7 @@ Creation and resume are one rollback-covered transaction: construct a private se 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. -Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → unwind scope → detach agent → detach session; the id becomes reusable after private scope cleanup. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, and per-step assembly goes through `assembleContextFor(agent)`. +Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → unwind scope → detach agent → detach session; the id becomes reusable after private scope cleanup. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, and per-step request assembly goes through `assembleRequestContextFor(agent)`. - `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index bc78c02fc0..54d3409e41 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -14,7 +14,7 @@ 调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose(资源释放)或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 -每个 agent 与其会话共享一个由调用方选择的 `SessionId`,并假设它在全局唯一;意外的 UUID 冲突不属于受支持模型。两个使用同一 id 的并发操作都可以进行准备,但最终的 `enter()` 调用会裁决发布,所有失败方都会回滚各自的私有资源。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。在同步创建通知期间请求的 detach 会等待该次分发退栈,从而保留 created/disposed 配对。Teardown 顺序为停止并 drain → 撤销作用域 → detach agent → detach 会话;私有作用域清理完成后,该 id 即可复用。普通、不可 veto 的 `agent/*` 通知通过 `agentEvents(ctx, agent)` 发出;逐步骤组装通过 `assembleContextFor(agent)` 完成。 +每个 agent 与其会话共享一个由调用方选择的 `SessionId`,并假设它在全局唯一;意外的 UUID 冲突不属于受支持模型。两个使用同一 id 的并发操作都可以进行准备,但最终的 `enter()` 调用会裁决发布,所有失败方都会回滚各自的私有资源。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。在同步创建通知期间请求的 detach 会等待该次分发退栈,从而保留 created/disposed 配对。Teardown 顺序为停止并 drain → 撤销作用域 → detach agent → detach 会话;私有作用域清理完成后,该 id 即可复用。普通、不可 veto 的 `agent/*` 通知通过 `agentEvents(ctx, agent)` 发出;逐步骤请求组装通过 `assembleRequestContextFor(agent)` 完成。 - `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent`:在确切共享的 agent/会话 id 下同步创建,不运行 setup,并随调用 fiber dispose。声明式配置把 `agents[].id` 视为稳定 label,通常会先生成 `${label}-session-`,再调用此边界。应用也可以提供稳定且确切的 `sessionId`:首次使用时创建;重新挂载且持久化内容已存在时,则恢复已经实体化的历史。`resumeSessionId` 要求并加载现有的持久化 id,且与 `sessionId` 互斥。这样,默认的全新重启不会冲突,也无需保留第二个实时路由身份。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 7954d31cf3..379a6e05d8 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -10,7 +10,7 @@ import type { Context } from 'cordis' import { randomUUID } from 'node:crypto' -import { agentCarrier, assembleContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent' +import { agentCarrier, assembleRequestContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { @@ -688,7 +688,7 @@ export class ReactLoopAgent implements Agent { // Assemble request-owned prompt inputs fresh each step. Dynamic context is // committed at the tail before deriving history once, preserving the stable // system/history cache prefix while keeping every model-visible byte logged. - const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) + const assembly = await this.loopCtx.systemPrompt.assemble(assembleRequestContextFor(this, signal)) signal.throwIfAborted() const system = renderPrompt(assembly) materializeRuntimeContext(session, renderContextSnapshot(assembly)) diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index f3d784679f..12b992bd92 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -161,7 +161,10 @@ describe('AgentLoop initiator scope', () => { } ctx.on('system-prompt/assemble', async (_assembly, context, next) => { - if (context.agent === agent) capture(context.signal) + if (context.agent === agent) { + expect(context.modelRequest).toBe(true) + capture(context.signal) + } return next() }) ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => { diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 8673595322..917978340e 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: 98421aa6de3d6778702665854ed723507e933028 -README.zh.md: bfc8d68a9656a29a809de0848986e4ee9eb3fe7c +README.md: 5af048065fc36ef9a571e7baa40ba51227bd9480 +README.zh.md: 8575f63dfbeecd77bd9a0348663dd91de0c164e9 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 98421aa6de..5af048065f 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 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. +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 an inspection-capable per-agent assembly context (`agent` + `scope` together), while `assembleRequestContextFor(agent)` additionally marks an assembly that the caller will materialize into the next model request. `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. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index bfc8d68a96..8575f63dfb 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 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建可用于检查的逐 agent 组装上下文(同时包含 `agent` + `scope`),而 `assembleRequestContextFor(agent)` 还会将组装标记为其结果将由调用方物化为下一个模型请求。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 `AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index b28586b6b8..54a57843c8 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -146,3 +146,15 @@ export function emitAgentEvent( export function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext { return { agent, scope: agent, ...signal === undefined ? {} : { signal } } } + +/** + * Build the prompt assembly context for the agent loop's next model request. + * Inspection callers use {@link assembleContextFor} so listeners cannot mistake + * a diagnostic assembly for an imminent request commit. + * @param agent - the agent the request assembly is for. + * @param signal - the current turn's explicit control signal, when available. + * @returns the agent-scoped context marked for request materialization. + */ +export function assembleRequestContextFor(agent: Agent, signal?: AbortSignal): AssembleContext { + return { ...assembleContextFor(agent, signal), modelRequest: true } +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 66dee4efb7..d98594eb48 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -17,7 +17,7 @@ import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export * from './brand.ts' export * from './llm-target.ts' -export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' +export { agentCarrier, agentEvents, assembleContextFor, assembleRequestContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' declare module 'cordis' { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 80d289c9f0..4bf2809677 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -15,6 +15,8 @@ declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ agent?: Agent + /** Present only when the caller will materialize this assembly into the agent's next model request. */ + modelRequest?: true } } diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 09f3af6cff..fc7b0a09e1 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -4,6 +4,8 @@ import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, + assembleContextFor, + assembleRequestContextFor, } from '@deepseek-ai/dsh-agent' import type { @@ -187,6 +189,23 @@ describe('agentEvents()', () => { }) }) +describe('agent prompt assembly context', () => { + it('marks only request-owned assemblies for model materialization', () => { + const agent = stubAgent('assembly') + const signal = new AbortController().signal + + expect(assembleContextFor(agent, signal)).toEqual({ agent, scope: agent, signal }) + expect(assembleRequestContextFor(agent, signal)).toEqual({ + agent, + scope: agent, + signal, + modelRequest: true, + }) + expect(assembleContextFor(agent)).toEqual({ agent, scope: agent }) + expect(assembleRequestContextFor(agent)).toEqual({ agent, scope: agent, modelRequest: true }) + }) +}) + describe('explicit cancellation contract', () => { it('exposes the closed typed cancellation cause at the Agent seam', () => { expectTypeOf[0]>().toEqualTypeOf() diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index a3937c24ec..72b4202062 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/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/system-prompt/README.md -README.md: d4e0f69323b7326fc7575834bf48a5aeeec0777e -README.zh.md: 47290335d725083fc46ef4f2ee09b09263276788 +README.md: a521baf05f46d82c63058e1ebbbffb1e46e3f312 +README.zh.md: 199f790faadff265005d0654c4cded45f94173a5 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index d4e0f69323..a521baf05f 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -28,7 +28,7 @@ Model-input assembly registry. Plugins contribute ordered stable system sections ### Key types -- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. +- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` and `modelRequest?: true`. Use `assembleContextFor(agent, signal)` for agent-scoped inspection and `assembleRequestContextFor(agent, signal)` only when the caller will materialize the result into the next model request. Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. - `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. - `PromptContext` — `{ name, order, text }`. Contexts carry changing current facts that must not rewrite the cached system/history prefix; they use the same per-assembly provider and strict-variable contracts as sections. - `PromptAssembly` — `{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record }`. Section and context texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 47290335d7..199f790faa 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -28,7 +28,7 @@ ### 关键类型 -- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 +- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent` 和 `modelRequest?: true`。使用 `assembleContextFor(agent, signal)` 进行 agent 作用域检查;只有当调用方会将结果物化为下一个模型请求时,才使用 `assembleRequestContextFor(agent, signal)`。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 - `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。 - `PromptContext`:`{ name, order, text }`。上下文承载不断变化的当前事实,这些事实不能改写已缓存的系统/历史前缀;上下文与段使用相同的逐组装提供方契约和严格变量契约。 - `PromptAssembly`:`{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record }`。段与上下文文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 From 1daa35b6e3a8fca62b9e42abed6a0d8cfc6ce398 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 16:02:17 +0800 Subject: [PATCH 003/179] feat(subagent): add Codex product provider --- ...code-and-codex-subagent-backends.i18n.yaml | 6 +- ...claude-code-and-codex-subagent-backends.md | 85 +- ...ude-code-and-codex-subagent-backends.zh.md | 85 +- THIRD_PARTY_NOTICES.md | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 7 +- docs/config-catalog.md | 19 + docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- docs/module-graph.md | 8 + .../subagent/subagent-codex/cordis.yml | 42 + .../subagent/subagent-codex/fixture.ts | 102 ++ .../subagent-codex/evidence.expected.json | 38 + .../subagent-codex/session.expected.jsonl | 25 + .../subagent-product-providers.snapshot.ts | 167 +++ examples/package.json | 2 + knip.json | 13 + packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 3 +- packages/subagent/README.zh.md | 3 +- .../subagent/subagent-codex/README.i18n.yaml | 6 + packages/subagent/subagent-codex/README.md | 88 ++ packages/subagent/subagent-codex/README.zh.md | 88 ++ packages/subagent/subagent-codex/package.json | 53 + packages/subagent/subagent-codex/src/index.ts | 89 ++ .../subagent/subagent-codex/src/invariant.ts | 30 + packages/subagent/subagent-codex/src/run.ts | 209 ++++ packages/subagent/subagent-codex/src/wire.ts | 366 ++++++ .../subagent-codex/tests/real-product.spec.ts | 230 ++++ .../subagent-codex/tests/responses-fixture.ts | 283 +++++ .../tests/subagent-codex.spec.ts | 1053 +++++++++++++++++ .../subagent/subagent-codex/tsconfig.json | 42 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 1 + packages/subagent/subagent/README.zh.md | 1 + pnpm-lock.yaml | 111 ++ scripts/gen-doc-graphs.ts | 6 +- tsconfig.host.json | 1 + vitest.config.ts | 1 + 45 files changed, 3170 insertions(+), 126 deletions(-) create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts create mode 100644 examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl create mode 100644 examples/acp-agent/tests/subagent-product-providers.snapshot.ts create mode 100644 packages/subagent/subagent-codex/README.i18n.yaml create mode 100644 packages/subagent/subagent-codex/README.md create mode 100644 packages/subagent/subagent-codex/README.zh.md create mode 100644 packages/subagent/subagent-codex/package.json create mode 100644 packages/subagent/subagent-codex/src/index.ts create mode 100644 packages/subagent/subagent-codex/src/invariant.ts create mode 100644 packages/subagent/subagent-codex/src/run.ts create mode 100644 packages/subagent/subagent-codex/src/wire.ts create mode 100644 packages/subagent/subagent-codex/tests/real-product.spec.ts create mode 100644 packages/subagent/subagent-codex/tests/responses-fixture.ts create mode 100644 packages/subagent/subagent-codex/tests/subagent-codex.spec.ts create mode 100644 packages/subagent/subagent-codex/tsconfig.json diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index ecb4e98def..27fb29dffd 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -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-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 14e8dde04d9526aaffc0e58be049e13858362887 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +2026-07-07-claude-code-and-codex-subagent-backends.md: 86a2e3489a84408e24c6c8091bc52b747b9069b9 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: ef2098b3afe3e5602ed93de1984c91a5c4c1e79e diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index ee8576f97a..86a2e3489a 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code and Codex subagent backends (out-of-process delegation to external coding agents) +# Agent Note: Claude Code and Codex subagent providers Status: proposed @@ -6,84 +6,69 @@ English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md) ## Problem -The subagent seam ([the seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine. +The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, use the parent Session's workspace, return a final answer or explicit failure, and leave no managed product process behind. + +Product integration must not create a second owner for task text, cwd, cancellation, result settlement, or process trees. It must also prove the real product path in required keyless tests: a fake wrapper or direct model HTTP request cannot establish that the Loader, provider registration, official product protocol, authentication, final answer, and teardown compose correctly. ## Proposal -Two sibling provider packages, structural variants of the ACP backend, plus one extraction: +Two sibling one-shot providers register fixed deployment names and are exposed through two fixed `dsh-tool-subagent` instances: -- `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter. -- `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. -- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. +- `@deepseek-ai/dsh-subagent-codex` registers `codex`, driven through `codex app-server --stdio`, and is implemented. +- `@deepseek-ai/dsh-subagent-claude-code` will register `claude-code`, driven through the official Claude Agent SDK and its bundled CLI, and remains pending. -Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. +The model-facing tools are `subagent_codex` and `subagent_claude_code`. Each tool binds one provider at deployment time, accepts a standalone task, and omits the background parameter in the initial compositions. Product selection is not another model argument. -## Verified interface facts (pinned versions) +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and use the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and one non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground disposal; the shared subprocess service owns environment scrubbing, process-tree termination, and whole-tree exit observation. -Both integration surfaces were verified against pinned implementations before this proposal — types and bundled source read, keyless spikes run — not from vendor docs alone. The pins are the verification baseline, not a runtime contract: the backends perform no runtime version probe (no `codex --version` gate, no SDK version sniffing). Compatibility is enforced at development time — every dependency bump re-runs the keyless suites against the real load path — and at runtime by failing loudly: a protocol-level surprise settles `error` via `onError`, never a silent misbehavior. +## Codex provider -**`@anthropic-ai/claude-agent-sdk` 0.3.202.** `options.env` REPLACES the child environment (no merge with `process.env`), which is exactly what the scrub needs. `settingSources` defaults to loading ALL filesystem settings — isolation requires explicitly passing `[]`. Result subtypes are `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`. On abort the SDK escalates the CLI child itself: stdin EOF immediately, SIGTERM ~2s later if the child ignores it (observed; no leftover processes) — no bespoke kill fallback needed. `outputFormat: {type: 'json_schema'}` and an `agents` option exist, giving future landing points for the seam's `outputSchema` capability and named subagent types; both are out of scope here. +The Codex provider has fixed name `codex` and fixed command `codex app-server --stdio`. Its public configuration contains only explicit `env` entries and a positive finite `disposeGraceMs`; it does not expose command, cwd, model, base URL, API key, sandbox, approval, product home, or session settings. Production resolves Codex from `PATH` and uses the host's native Codex configuration and authentication. Credential-shaped ambient variables are scrubbed by `dsh-subprocess`, while explicit `env` values merge afterward. -**codex CLI 0.142.5, `codex app-server` (v2 vocabulary).** LF-delimited JSON, JSON-RPC 2.0 shapes with the `"jsonrpc"` header omitted. +Before publication, the provider validates a non-empty text-only task, starts the managed app-server, performs `initialize` → `initialized`, and creates an `ephemeral: true` thread in the parent workspace. The returned run owns exactly one `turn/start`; product thread and turn ids stay private and are not persisted in the parent Session. -- Lifecycle: `initialize{clientInfo}` + `initialized` → `thread/start` (accepts `cwd`, `model`, `sandbox`, `approvalPolicy`, `ephemeral`; succeeds unauthenticated) → `turn/start{threadId, input:[{type:'text',text}]}` returns an `inProgress` turn immediately; the terminal signal is the `turn/completed` notification carrying `Turn{status: completed|interrupted|failed|inProgress, error}`. -- Approvals are server-initiated requests — `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request` — answered with `accept`/`decline`-family decisions. -- Auth: `account/login/start{type:'apiKey', apiKey}` is a first-class RPC and `account/read` reports `requiresOpenaiAuth` — and an unauthenticated `turn/start` does NOT fail fast (it hangs in retry), so the backend MUST pre-check auth and settle `error` loudly instead of waiting on the turn. -- Isolation: `CODEX_HOME` redirection is honored (the `initialize` response echoes it, so tests can assert isolation), and `ephemeral: true` threads leave no session files at all. +`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins, with the latest nullable-phase message as the compatibility fallback; commentary never replaces an answer. A completed turn without an answer, a failed or interrupted remote turn, malformed payload, protocol closure, early process exit, or unknown server request becomes a shared `error`. Local cancellation wins the race and remains `aborted`. -## Isolation and credentials +The unattended wire declines command and file approvals, grants no requested permissions for the turn, and declines MCP elicitation. It fails closed for every other server request instead of waiting for UI that this provider does not supply. -Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`. +Publication transfers the wire and process handle to one holder. Idempotent disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. An unpublished startup failure performs the same cleanup before `start()` rejects. -## Permission and approval policy +## Claude Code provider -Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP. +The Claude Code sibling follows the same fixed-name, self-contained, one-shot, parent-cwd, shared-result, and managed-tree boundaries. Its product-specific implementation will use the official Agent SDK's `query()` and spawn hook, keep SDK protocol ownership separate from `dsh-subprocess` process-tree ownership, omit human-interaction callbacks, and derive only a strict final SDK result after the message iterator ends normally. -## StopReason mapping +The Claude package will expose the same two configuration concerns, `env` and `disposeGraceMs`. Product installation, native settings, and login remain deployment responsibilities rather than plugin-managed state. This note stays proposed until that sibling and the combined two-product evidence are implemented. -Claude Code: `success` → `completed`; `error_max_turns`, `error_during_execution`, `error_max_budget_usd`, `error_max_structured_output_retries` → `error` (aligning with the ACP call on `max_turn_requests`: an unfinished task is not success); generator abort → `aborted`; anything unknown → `error`. Codex: `Turn.status` `completed` → `completed`; `interrupted` → `aborted`; `failed` with `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`, any other `failed` → `error`; transport/spawn/auth-precheck failure → `error` (or `aborted` if cancel was requested). In both, `cancel()` is the ACP shape: flag + abort/interrupt + a cancel-settled race arm so an uncooperative child cannot stall the result. +## Evidence contract -Liveness posture, stated explicitly: teardown timing is config, turn duration is not. Both backends take the dispose ladder's grace periods as defaulted validated config fields (the ACP backend's `disposeEofGraceMs`/`disposeGraceMs` shape, carried by the extraction), but there is deliberately NO turn-duration or startup timeout — matching ACP, liveness during a turn belongs to the caller via `cancel()`/the abort signal, a subagent turn is legitimately minutes long, and the Codex auth precheck removes the one verified guaranteed-hang; a deployment wanting a wall-clock bound cancels from the parent. +Each product owns package-level branch-complete tests, a required real-product spec, and a real Loader snapshot. The real-product tier must use the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product configuration, and a loopback fixed-answer model; it fails rather than skips when the binary, authentication request, task, answer, cancellation, or process-exit proof is missing. -## Testing - -Named at every tier per the root AGENTS.md rule, and de-risked up front: - -- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape. -- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy. -- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile. +The Codex evidence pins `@openai/codex@0.146.0` / `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and every managed handle reaching whole-tree quiescence. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, full persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still uses `codex` from `PATH`. ## Alternatives considered -### Why not the official `@openai/codex-sdk` instead of a hand-rolled client? +**Direct model HTTP or `codex exec`.** These paths bypass the products' official extensible process protocols and cannot prove product configuration, tools, approvals, lifecycle, or teardown. The providers use app-server and the official Agent SDK instead. -The dispose ladder and env scrub require owning the child process (spawn args, env, signals, exit await); the SDK hides the process. The wire format is trivial to frame (LF JSON), the shapes are generatable per pinned version (`codex app-server generate-json-schema`), and the repo precedent (`hook-protocol`) is to own thin protocol cores rather than wrap someone's runtime. The SDK would save protocol-evolution maintenance but costs the exact control this backend exists to have. +**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership before two production consumers demonstrated a missing common contract, so product-specific adapters call the existing seams directly. -### Why not a model-visible `subagent_type` parameter (one Task-style tool)? +**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. -Claude Code's own Task tool puts the subagent type in the model-facing schema, selecting a prompt-plus-toolset persona. Here the choice is between EXECUTION ENGINES, and only the deployer knows which engines have credentials configured — so selection stays deployment config, preserving `dsh-tool-subagent`'s documented one-provider-per-tool contract. A persona-style type selector would be a separate Agent Note against the tool, not the backends. +**Product doubles as required evidence.** Doubles are useful for exhaustive private protocol branches but do not prove package exports, official binaries, authentication, or real process behavior. Required evidence drives the official product against loopback model fixtures. -### Why not login-state credentials and the user's own config? +**Plugin-managed login, product home, models, or permissions.** Those settings would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only explicit environment overlay and teardown grace; unattended interaction fails closed. -Inheriting `~/.claude` / `~/.codex` (subscription login, user settings, skills, MCP servers) would make child behavior depend on host-machine state and punch an implicit exception through the "credentials enter explicitly via `config.env`, never ambiently" rule the ACP backend and bash executor established. API-key-only plus forced config-dir isolation keeps runs reproducible; deployments wanting shared state can point the config-dir field at a persistent directory deliberately. - -### Why not a driver-injection seam for the Claude Code keyless tests? - -Injecting a fake `query()` would mock our own boundary and leave the real SDK load path untested (the real-over-mock policy in docs/testing.md). The risk that justified considering it — the SDK↔CLI stream-json control protocol being internal — was retired by the spike: the fake-CLI harness works against the real pinned SDK today. If an SDK upgrade breaks the mock, the keyless suite fails the upgrade PR, which is the gate working. - -### Why not ACP adapters (e.g. `claude-code-acp`) reusing the existing backend? - -Community shims wrap both engines in ACP, which would make them "just config" on `dsh-subagent-acp`. But that inserts an unofficial third-party layer between the harness and the engine, erases the native control surfaces this Agent Note exposes (permissionMode, sandboxMode/approvalPolicy, config-dir isolation, apiKey RPC), and trades first-party protocol stability for a shim's release cadence. First-party surfaces — the Agent SDK and the app-server — are the supported integration points. +**Continuation, progress, and shared parent context.** The first user result needs one self-contained task and one final answer. Product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and background collection need separate user contracts and are not prebuilt. ## Acceptance criteria -On a machine with both engines and keys configured: a REPL-driven model completes one real file task through `subagent_claude_code` and one through `subagent_codex`, the tool result being the child's final answer, with only `tool/call` + `tool/result` in the parent session log. Keyless suites pass at 100% per-file coverage in a credential-less environment, asserting isolation (scrubbed child env, no temp config dirs left after dispose) and that child behavior is unchanged by the presence or absence of `~/.claude` / `~/.codex`. Cancelling a parent turn quiesces both backends in bounded time with no leftover child processes. E2e suites self-skip cleanly, naming the missing prerequisite. +The proposal is complete when both fixed tools reach their corresponding real products through the Loader, return exact final answers or explicit failure/cancellation, persist the complete model-visible parent transcript, and prove managed process-tree quiescence in required keyless CI. Both packages have complete configuration, lifecycle, failure, model-experience, and limitation documentation; the generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. + +The implemented Codex half already satisfies this contract for its fixed tool and 0.146.0 product baseline. The note remains proposed because the Claude Code sibling and combined final evidence are not yet implemented. ## Risks -- `codex app-server` is CLI-flagged experimental and its v1/v2 vocabularies coexist; the client pins 0.142.5, implements v2 only, and consumes unknown methods/notifications without crashing, but a future codex bump can still force rework (regenerate schemas and re-run the keyless suite on every bump — the development-time enforcement behind the no-runtime-version-probe stance above). -- The Claude Code fake-CLI mock rides an internal protocol: any SDK upgrade must go through the keyless suite, and a breaking control-protocol change means reworking the mock (fallback: the driver-injection seam rejected above becomes the escape hatch). -- The SDK's optionalDependencies weigh ~280MB per platform — accepted, and confined to the one backend package. -- The SDK's SIGKILL branch beyond EOF→SIGTERM was not observed and is trusted; e2e keeps a no-leftover-process assertion. -- Codex is a deployment prerequisite (no npm-bundled binary); a missing or incompatible binary surfaces as a loud spawn/protocol `error`, not a version probe. -- Every run pays a fresh child process and only the final answer surfaces — thoughts, tool cards, and usage are consumed and dropped; pooling, intermediate-progress surfacing, `sendMessage`/`resume`, `outputSchema` via the SDK's `outputFormat`, and named subagent types via the SDK's `agents` option are all deliberate deferrals. +- The Codex app-server protocol is product-versioned and may change; production performs no runtime version probe, so every supported baseline change must refresh schema investigation and real-product compatibility evidence. +- Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's own authority. +- Every delegation pays for a fresh process and independent model context, and only final text reaches the parent. +- Product tool or file side effects are not rolled back when a run fails or is cancelled. +- Unattended approval denial keeps the initial provider safe from interactive hangs but cannot satisfy tasks that require new permission. diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 14e8dde04d..ef2098b3af 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent(智能体)的进程外委派) +# Agent Note: Claude Code 与 Codex subagent 提供方 Status: proposed @@ -6,84 +6,69 @@ Status: proposed ## 问题 -subagent seam([seam Agent Note(agent 决策记录)](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP(Agent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为机械上相似的兄弟。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 +命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子 agent 的运行方式即可委派工作,但 harness 需要接入真实 Codex 与 Claude Code 产品的第一方路径。一个实用的首版必须能把一个自包含任务交给任一产品,使用父会话的工作区,返回最终答案或明确失败,并且不留下任何受管产品进程。 + +产品集成不得让任务文本、工作目录、取消、结果结算或进程树出现第二个所有者。它还必须在强制无密钥测试中证明真实产品链路:假包装层或直接向模型发起的 HTTP 请求无法证明 Loader、提供方注册、官方产品协议、认证、最终答案和清理能够正确组合运行。 ## 提案 -两个兄弟提供方包(package),作为 ACP 后端的结构变体,另加一次提取: +两个同级的单次执行提供方注册固定部署名称,并通过两个固定的 `dsh-tool-subagent` 实例对外提供: -- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI(命令行界面)作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——「claude」保留给未来的 `dsh-llm` 适配器。 -- `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread/turn,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。 -- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose(资源释放)阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 +- `@deepseek-ai/dsh-subagent-codex` 注册 `codex`,由 `codex app-server --stdio` 驱动,现已实现。 +- `@deepseek-ai/dsh-subagent-claude-code` 将注册 `claude-code`,由官方 Claude Agent SDK 及其捆绑的 CLI(命令行界面)驱动,目前仍待实现。 -两个提供方逐字复制 ACP 后端的 seam 姿态:每次 `start` 创建全新子进程、恰好一次提示词往返、所有能力均为 `false`、`inheritsParentContext: false`、忽略 `request.parent`/`request.agentOptions`、`id = SessionId(randomUUID())`,且 `result` 从不 reject——子进程级失败扁平化为 stop reason,原始错误则通过 `onError` spec 回调送到 `ctx.logger`。模型暴露无需新代码:每个提供方各加载一次 `dsh-tool-subagent`,使用不同的 `toolName`(`subagent_claude_code`、`subagent_codex`)。无需新的会话事件——唯一的模型可见产物是工具结果,因此可重建性与 ACP 完全相同。明确边界:会话日志重建模型可见的 transcript(文本记录),而不是工作区变更历史——获准写入的子进程将文件作为日志之外的环境副作用进行修改,与 bash 工具和 ACP 后端现有行为完全一致;回放复现请求,而非磁盘。 +面向模型的工具为 `subagent_codex` 和 `subagent_claude_code`。每个工具在部署时绑定一个提供方,接受一个独立任务,并在初始组合中省略后台参数。产品选择不作为额外的模型参数。 -## 已验证的接口事实(固定版本) +两个提供方均报告 `inheritsParentContext: false`,不声明任何可选启动能力,并使用父会话的工作目录而不复制父会话对话。每次调用都会创建一个全新的产品进程和一次不可恢复的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台 dispose(资源释放);共享子进程服务负责环境清洗、进程树终止和整棵进程树的退出观测。 -两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过大声失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。 +## Codex 提供方 -**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行逐级加强对 CLI 子进程的终止措施:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 Agent Note 范围内。 +Codex 提供方的固定名称为 `codex`,固定命令为 `codex app-server --stdio`。其公开配置只包含显式 `env` 条目和取正有限值的 `disposeGraceMs`;不公开命令、工作目录、模型、基础 URL、API 密钥、沙箱、审批、产品主目录或会话设置。生产环境从 `PATH` 解析 Codex,并使用宿主机原生的 Codex 配置和认证。`dsh-subprocess` 会清洗环境中形似凭证的变量,之后再合并显式 `env` 值。 -**codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。 +在发布运行实例前,提供方会验证任务非空且仅含文本,启动受管 app-server,依次执行 `initialize` → `initialized`,并在父工作区中创建一个 `ephemeral: true` 线程。返回的运行实例只负责一次 `turn/start`;产品线程 ID 和轮次 ID 始终为私有信息,不会持久化到父会话中。 -- 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的轮次;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。 -- 审批是服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。 -- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时大声结算为 `error`,而非等待轮次。 -- 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),`ephemeral: true` 的 thread 不留任何会话文件。 +`turn/completed` 是判定远端终止状态的权威依据。最新一条内容非空且带有 `phase: "final_answer"` 的 `agentMessage` 优先;阶段字段可为空值的最新消息作为兼容回退。过程说明绝不取代答案。已完成但无答案的轮次、失败或中断的远端轮次、格式错误的载荷、协议关闭、进程提前退出或未知服务端请求,都会结算为共享的 `error`。本地取消会在竞态中胜出,结果仍为 `aborted`。 -## 隔离与凭证 +无人值守通信层会拒绝命令审批和文件审批,对于该轮次请求的权限一概不予授予,并拒绝 MCP elicitation。对于其他所有服务端请求,它都会以失败响应,而不会等待本提供方并未提供的 UI。 -部署只使用 API key 认证,子进程不得看到宿主用户的 Claude Code / Codex 配置:行为必须只由 `cordis.yml` 决定。每次运行获得一个全新的 `mkdtemp` 配置目录——Claude Code 使用 `CLAUDE_CONFIG_DIR`(并显式设置 `settingSources: []`),Codex 使用 `CODEX_HOME`——dispose 时尽力删除;配置字段也可以固定一个持久目录。子进程环境通过提取逐字复用 ACP 后端的 `buildChildEnv` 语义:转发环境变量,但移除凭证形态的变量(`/KEY|SECRET|TOKEN/i`),再叠加 `config.env`——因此 `PATH`、`HOME`、`TMPDIR`、locale 和代理变量保留,CLI 正常运行;只有环境中的凭证形态变量被清洗(Claude Code 的 `ANTHROPIC_API_KEY` 通过 `config.env` 显式进入),Codex key 则通过 `account/login/start` RPC 进入隔离的 `CODEX_HOME`,而非手写 `auth.json`。 +发布时,协议连接和进程句柄会移交给唯一持有者。幂等 dispose 会尽力中断已知轮次、关闭协议连接、结束 stdin、调用共享的逐级终止流程,并等待整棵进程树退出。若启动在发布前失败,`start()` 会先执行同样的清理,再以拒绝结束。 -## 权限与审批策略 +## Claude Code 提供方 -每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有漏过请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中提示词不会到达人类,与 ACP 一致。 +Claude Code 同级提供方沿用相同边界:名称固定、任务自包含、仅执行一次、使用父级工作目录、结果由共享服务结算,且进程树受管。其产品专用实现将使用官方 Agent SDK 的 `query()` 与 spawn 钩子,将 SDK 协议所有权同 `dsh-subprocess` 的进程树所有权分开,不设置人机交互回调,并且仅在消息迭代器正常结束后提取严格的最终 SDK 结果。 -## StopReason 映射 +Claude 包将公开相同的两个配置项:`env` 和 `disposeGraceMs`。产品安装、原生设置和登录仍由部署方负责,插件不管理这些内容。在该同级提供方及两种产品的组合证据实现之前,本文仍处于 proposed 状态。 -Claude Code:`success` → `completed`;`error_max_turns`、`error_during_execution`、`error_max_budget_usd`、`error_max_structured_output_retries` → `error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不是成功);生成器中止 → `aborted`;未知值 → `error`。Codex:`Turn.status` 为 `completed` → `completed`;`interrupted` → `aborted`;`failed` 且 `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`,其他 `failed` → `error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。 +## 证据契约 -活性姿态,明确声明:teardown 时序是配置项,轮次时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意不设轮次时长或启动超时——与 ACP 一致:轮次期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent 轮次持续数分钟也属合理,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。 +每个产品都有包(package)级分支完备测试、一项必需的真实产品规格测试,以及一份真实 Loader 快照。真实产品层必须使用受测的确切官方发行包、非空的假产品密钥、隔离的临时工作区与产品配置,以及固定答案的环回模型;如果缺少二进制文件、认证请求、任务、答案、取消或进程退出证明中的任一项,该层必须失败而非跳过。 -## 测试 - -依照根 AGENTS.md 规则在每个层级明确命名,并预先消除风险: - -- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR(热模块替换)提供方清理、导出形状、子进程环境隔离断言和临时目录删除;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并讲 plain stream-json,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。 -- **有密钥 e2e 测试**:每个后端的真实引擎执行并由磁盘验证真实文件工作,固定使用开放后的配置,以免验收与不造成损害的默认值冲突——Claude Code 使用 `permissionMode: 'acceptEdits'`,Codex 使用 `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'`;自跳过会准确报告缺失的是二进制还是 key。CI 没有密钥,因此依照有密钥策略在本地运行。 -- **快照测试**:以 `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` 推迟——即 ACP 后端也推迟的独立回放形状([按会话回放 Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md));在此期间由无密钥套件提供确定性覆盖。 +Codex 证据固定使用 `@openai/codex@0.146.0` / `codex-cli 0.146.0`。其真实产品规格测试会观测确切的 Bearer 密钥、原始任务、字节完全一致的最终答案、无人值守下命令被拒绝且不产生文件副作用、本地取消,以及每个受管句柄对应的整棵进程树均达到完全停稳。其 Loader 快照固定记录不含后台参数的工具 schema、确切的工具调用与工具结果、完整持久化的父会话、产品请求,以及清理前的完全停稳状态。该 npm 包是用于提供可复现证据的开发依赖;生产环境仍使用 `PATH` 中的 `codex`。 ## 曾考虑的替代方案 -### 为什么不用官方 `@openai/codex-sdk` 而手写客户端? +**直接向模型发起 HTTP 请求或 `codex exec`。** 这些路径会绕过产品官方的可扩展进程协议,无法证明产品配置、工具、审批、生命周期或清理。提供方改用 app-server 和官方 Agent SDK。 -dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式(wire format)极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),仓库先例(`hook-protocol`)是拥有薄协议核心而非包装他人的运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。 +**共享产品进程辅助包。** 现有 subagent seam 和子进程 seam 已经负责所有共享任务、结果、环境和进程树关注点。在两个生产消费方证明通用契约确有缺口之前,新辅助包会造成所有权重复,因此产品专用适配器直接调用现有 seam。 -### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)? +**面向模型的产品选择器。** 产品可用性与认证属于部署事实。两个固定工具让各自的 schema 和提供方绑定保持显式,并避免向通用服务加入动态选择状态。 -Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个提示词 + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 Agent Note,而非针对后端。 +**将产品替身作为必需证据。** 替身适合完整覆盖私有协议分支,但无法证明包导出、官方二进制文件、认证或真实进程行为。必需证据使用环回模型 fixture(测试前置数据)驱动官方产品。 -### 为什么不用登录态凭证和用户自身的配置? +**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外另立一个管理权威,并把单次执行提供方变成账户管理功能。提供方只公开显式环境叠加和清理宽限期;无人值守交互一律以失败响应。 -继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill(技能)、MCP 服务器)会使子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打开一个隐式例外。仅 API key 加强制配置目录隔离使运行可复现;需要共享状态的部署可以有意将配置目录字段指向一个持久目录。 - -### 为什么不为 Claude Code 无密钥测试注入驱动层 seam? - -注入假的 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部实现——已被 spike 消除:假 CLI harness 今天能对真实固定版本的 SDK 正常工作。如果 SDK 升级破坏了 mock,无密钥套件会让升级 PR(Pull Request)失败,这正是门禁在发挥作用。 - -### 为什么不用 ACP 适配器(如 `claude-code-acp`)复用既有后端? - -社区 shim 将两个引擎包装为 ACP,这会使它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方的第三方层,抹去了本 Agent Note 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏替换了第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。 +**续接、进度与共享父级上下文。** 首版面向用户的功能只需接收一个自包含任务,并返回一个最终答案。产品会话、恢复、后续请求、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集各自需要独立的用户契约,本提案不会预先构建这些内容。 ## 验收标准 -在两个引擎和密钥均已配置的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终答案,父会话日志中仅有 `tool/call` + `tool/result`。无密钥套件在无凭证环境下以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程环境、dispose 后无残留临时配置目录),并断言 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内完全停稳,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。 +当两个固定工具都能通过 Loader 接入各自的真实产品,返回精确的最终答案或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制无密钥 CI 中证明受管进程树完全停稳时,本提案即告完成。两个包都具备覆盖配置、生命周期、失败、模型体验与限制的完整文档;生成的包记录、配置记录、能力记录、依赖记录和第三方记录均与已发布的 manifest(元数据清单)一致。 + +已实现的 Codex 部分已经针对其固定工具和 0.146.0 产品基线满足此契约。本文仍处于 proposed 状态,因为 Claude Code 同级提供方和两种产品的最终组合证据尚未实现。 ## 风险 -- `codex app-server` 被 CLI 标记为实验性,其 v1/v2 词汇共存;客户端固定 0.142.5、仅实现 v2、对未知方法/通知消费而不崩溃,但未来 codex 升级仍可能迫使返工(每次升级重新生成 schema 并重跑无密钥套件——这是上述「不做运行时版本探测」立场背后的开发时强制执行)。 -- Claude Code 假 CLI mock 依赖一个内部协议:任何 SDK 升级都必须通过无密钥套件,控制协议的破坏性变更意味着返工 mock(回退方案:上面否决的驱动注入 seam 成为逃生舱口)。 -- SDK 的 optionalDependencies 每平台约 280MB——已接受,限制在单个后端包内。 -- SDK 的 SIGKILL 分支(EOF→SIGTERM 之后)未被观察到,信任其实现;e2e 保留无残留进程断言。 -- Codex 是部署前置条件(无 npm 内置二进制);缺失或不兼容的二进制以大声的 spawn/协议 `error` 呈现,而非版本探测。 -- 每次运行付出一个全新子进程的代价,且仅最终答案浮出——思考、工具卡片和用量被消费后丢弃;连接池、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意推迟。 +- Codex app-server 协议随产品版本演进,可能发生变化;生产环境不执行运行时版本探测,因此每次变更受支持的基线时,都必须重新开展 schema 调查并更新真实产品兼容性证据。 +- 产品原生配置使行为取决于部署环境中安装的产品及其账户状态。强制测试会隔离这些输入,而生产环境则刻意让这些输入继续由产品自身掌控。 +- 每次委派都要承担启动全新进程和使用独立模型上下文的成本,而且只有最终文本会传回父 agent。 +- 运行失败或被取消时,产品工具或文件副作用不会回滚。 +- 无人值守模式下拒绝审批可防止初始提供方因交互而挂起,但无法满足需要新权限的任务。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8cd2964da6..5c1eb10acf 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -96,6 +96,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | | [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index b5136c31df..d2c60f23e1 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: ee50e249f8e588a3f92f57db929c6bbfb1c853dd -architecture.zh.md: c2e596cd10c21be2bcaad11323277d04ef9e74a1 +architecture.md: af6c6fee0c11fcf2935956044996137367932a8b +architecture.zh.md: f399deb29efd3d64b63427c597de9f612ac37643 diff --git a/docs/architecture.md b/docs/architecture.md index ee50e249f8..af6c6fee0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -167,7 +167,7 @@ Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is on A swappable capability usually has **interface / implementation / consumer** layers: service/events, backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. -Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, use ACP children, or delegate one self-contained turn to a real product provider such as Codex ([subagent.md](core-data-structures/subagent.md)). `dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c2e596cd10..f399deb29e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -167,7 +167,7 @@ idle inject: 可替换功能通常具有**接口/实现/消费方**三层:服务和事件、后端、面向模型的工具和提示词。Bash 是参考实现;[功能图](capability-seams.md)映射了每个包族。 -例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 +例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀、使用 ACP(Agent Client Protocol)子 agent,或将一个独立完整的轮次委派给 Codex 等真实产品提供方([subagent.md](core-data-structures/subagent.md))。 `dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 3976c1fb24..44af0b5e76 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -103,6 +103,7 @@ flowchart LR pkg_bash_sandbox["bash-sandbox"] pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_codex["subagent-codex"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] @@ -223,6 +224,7 @@ flowchart LR pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents + pkg_subagent_codex --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents pkg_subprocess --> svc_subprocess @@ -311,6 +313,7 @@ flowchart LR svc_subprocess --> pkg_bash_sandbox svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp + svc_subprocess --> pkg_subagent_codex svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -370,7 +373,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | - | The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | @@ -381,7 +384,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f85d39dcd7..8ac330c3f9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1519,6 +1519,25 @@ export type PermissionPolicy = 'allow' | 'reject' Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) +## `@deepseek-ai/dsh-subagent-codex` + +Requires: `subagents` · `subprocess` + +```ts config-catalog +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for app-server process-tree termination. */ + disposeGraceMs?: number +} +``` + +Source: [`packages/subagent/subagent-codex/src/index.ts:29`](../packages/subagent/subagent-codex/src/index.ts) + ## `@deepseek-ai/dsh-subagent-dsh-sdk` Requires: `subagents` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index a4c20672c0..f2438bea7a 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 07073c39f8a9b998b09b0815257d995b174c8be7 -extension-cookbook.zh.md: 10664af9a39f0a1663c316869ba8ef02f67c29fe +extension-cookbook.md: 379bd2644a4a7de005c8ea56d4857b6a6f9143b8 +extension-cookbook.zh.md: 09a5163c3b47e52e0e0e88f000a8f04302fa93e4 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 07073c39f8..379bd2644a 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -118,7 +118,7 @@ Every product feature maps to a listener on a documented extension seam — the | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | -| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`) + `dsh-tool-subagent` exposing one configured provider to the model | +| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`) + `dsh-tool-subagent` exposing one configured provider to the model | | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 10664af9a3..09a5163c3b 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -118,7 +118,7 @@ export function apply(ctx: Context) { | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | -| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | +| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d5de81fa45..d5682a47bc 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: c5fbf80ae71f99606dd86e38f06a4511b4ae4c73 -subagent.zh.md: 42c1fa7cb10863c1aa4ae975171b901207c08b85 +subagent.md: 917913470da389dccac83e455cf23486a94c23b1 +subagent.zh.md: efe12a5a5fe40d664f3071036157acd704b10c57 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c5fbf80ae7..917913470d 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 42c1fa7cb1..efe12a5a5f 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为四个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/module-graph.md b/docs/module-graph.md index b2a70d7ab7..35f832154b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_codex["subagent-codex"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] @@ -1004,6 +1005,12 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_subagent_codex --> pkg_invariants + pkg_subagent_codex --> pkg_llm + pkg_subagent_codex --> pkg_sdk_protocol + pkg_subagent_codex --> pkg_session + pkg_subagent_codex --> pkg_subagent + pkg_subagent_codex --> pkg_subprocess pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -1225,6 +1232,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml new file mode 100644 index 0000000000..cc50ea2587 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -0,0 +1,42 @@ +# Test-only composition: one real Codex app-server delegation through the +# Loader, fixed provider tool, common foreground settlement, and JSONL store. +- id: fixture + name: './fixture.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.DSH_TEST_OPENAI_API_KEY + CODEX_HOME: !!js process.cwd() + '/codex-home' + HOME: !!js process.cwd() + XDG_CONFIG_HOME: !!js process.cwd() + '/xdg' + PATH: !!js process.env.PATH + HTTP_PROXY: '' + HTTPS_PROXY: '' + ALL_PROXY: '' + NO_PROXY: '127.0.0.1,localhost' + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'Delegate the task through the fixed Codex tool.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts new file mode 100644 index 0000000000..9618c83654 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts @@ -0,0 +1,102 @@ +/** Deterministic parent model and process-quiescence observer for the Codex Loader snapshot. */ + +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + SubprocessHandle, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' + +const CODEX_TASK = 'Return the Loader snapshot sentinel exactly.' +const QUIESCENCE_FILE = '.codex-quiescence.json' + +function toolResultText(options: GenerateOptions): string { + return options.messages.at(-1)?.content + .filter(block => block.type === 'tool-result') + .flatMap(block => block.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') ?? '' +} + +class CodexDelegatingAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const result = toolResultText(options) + if (result.length === 0) { + const args = JSON.stringify({ + description: 'Codex Loader snapshot', + prompt: CODEX_TASK, + }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 0, + id: CallId('call-codex-loader'), + name: 'subagent_codex', + argumentsDelta: args, + } + yield { + type: 'block-end', + index: 0, + block: { + type: 'tool-call', + id: CallId('call-codex-loader'), + name: 'subagent_codex', + arguments: args, + }, + } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + + const reply = `Codex child returned: ${result}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +interface ObservedProcess { + readonly spec: SubprocessSpawnSpec + readonly handle: SubprocessHandle +} + +export const name = 'codex-loader-snapshot-fixture' +export const inject = ['llm', 'subprocess'] + +/** + * Register the deterministic parent adapter and record whether every spawned + * product tree was already quiet when the assembled application disposed. + * @param ctx - Loader context supplying the LLM and subprocess seams. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new CodexDelegatingAdapter()) + ctx.effect(() => { + const observed: ObservedProcess[] = [] + const originalSpawn = ctx.subprocess.spawn.bind(ctx.subprocess) + ctx.subprocess.spawn = (spec: SubprocessSpawnSpec): SubprocessHandle => { + const handle = originalSpawn(spec) + observed.push({ spec, handle }) + return handle + } + return async () => { + ctx.subprocess.spawn = originalSpawn + const alreadyExited = AbortSignal.abort() + const processes = await Promise.all(observed.map(async ({ spec, handle }) => ({ + argv: [...spec.argv], + quiescent: await handle.waitForExit(alreadyExited), + outcome: await handle.done, + }))) + await writeFile( + join(process.cwd(), QUIESCENCE_FILE), + `${JSON.stringify({ processes })}\n`, + ) + } + }, 'codex Loader snapshot process observer') +} diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json b/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json new file mode 100644 index 0000000000..f6f9b995ae --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json @@ -0,0 +1,38 @@ +{ + "stdout": { + "type": "result", + "success": true, + "sessionId": "{{sessionId}}", + "turn": 1, + "result": "Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0", + "reason": { + "kind": "completed" + }, + "usage": { + "inputTokens": 20, + "outputTokens": 61 + } + }, + "request": { + "method": "POST", + "path": "/v1/responses", + "authorization": "Bearer dsh-fake-openai-loader-key", + "taskObserved": true + }, + "quiescence": { + "processes": [ + { + "argv": [ + "codex", + "app-server", + "--stdio" + ], + "quiescent": true, + "outcome": { + "exitCode": 0, + "signal": null + } + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl new file mode 100644 index 0000000000..e15b81ddf0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl @@ -0,0 +1,25 @@ +{"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":"Delegate through Codex once."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Delegate through Codex once.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"mock","model":"mock-delegate"},"system":"{{system}}","tools":[{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent_codex","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}}]},"reason":"initial"}} +{"type":"request/context","seq":5,"time":0,"data":{"provider":"mock","model":"mock-delegate"}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-codex-loader","name":"subagent_codex","argumentsDelta":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}} +{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-codex-loader"},"content":[{"type":"tool-result","toolCallId":"call-codex-loader","content":[{"type":"text","text":"REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":56}}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":56}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts new file mode 100644 index 0000000000..6df5b7f411 --- /dev/null +++ b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts @@ -0,0 +1,167 @@ +/** + * Real-product Loader snapshots for fixed subagent providers. + * + * PR1 owns the Codex scenario. PR2 extends this file with the sibling Claude + * Code scenario and reruns both from its final stacked candidate. + */ + +import { dirname, delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { + normalizeSessionLog, + normalizeStdout, + scrubSystemPrompts, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' +import { startResponsesFixture } from '../../../packages/subagent/subagent-codex/tests/responses-fixture.ts' + +const testsDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) +const fixtureDir = join(testsDir, 'fixtures/subagent/subagent-codex') +const configPath = join(fixtureDir, 'cordis.yml') +const snapshotDir = join(testsDir, 'snapshots/subagent-codex') +const sessionExpected = join(snapshotDir, 'session.expected.jsonl') +const evidenceExpected = join(snapshotDir, 'evidence.expected.json') +const cliBin = join(repoRoot, 'packages/examples/cli-demo/src/bin.ts') +const repoTsconfig = join(repoRoot, 'tsconfig.json') +const codexBinDir = join( + repoRoot, + 'packages/subagent/subagent-codex/node_modules/.bin', +) +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' +const CODEX_SENTINEL = 'REAL_CODEX_LOADER_SENTINEL_0_146_0' +const FAKE_KEY = 'dsh-fake-openai-loader-key' + +interface PersistedSession { + readonly content: string + readonly header: { + readonly id: string + readonly cwd: string + } +} + +async function onlySession(root: string): Promise { + const paths = (await readdir(root, { recursive: true })) + .filter(path => path.endsWith('.jsonl')) + expect(paths).toHaveLength(1) + const path = paths[0] + if (path === undefined) throw new Error('Codex Loader snapshot persisted no session') + const content = await readFile(join(root, path), 'utf8') + const header = JSON.parse(content.slice(0, content.indexOf('\n'))) as PersistedSession['header'] + return { content, header } +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +describe('real product subagent providers through the Loader', () => { + it('pins the Codex tool, result, persisted Session, and process quiescence', async () => { + const responses = await startResponsesFixture([ + { kind: 'complete', text: CODEX_SENTINEL }, + ]) + let session: PersistedSession | undefined + let quiescence: unknown + try { + const result = await runLoaderSmoke({ + label: 'Codex subagent Loader snapshot', + tempDirPrefix: 'dsh-subagent-codex-loader-', + binScript: cliBin, + configPath, + binArgs: [ + '--config', + configPath, + '--output-format', + 'json', + 'Delegate through Codex once.', + ], + tsconfigPath: repoTsconfig, + processTimeoutMs: 45_000, + env: { + DSH_TEST_OPENAI_API_KEY: FAKE_KEY, + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + }, + async prepare(cwd): Promise { + const codexHome = join(cwd, 'codex-home') + await mkdir(codexHome) + await writeFile(join(codexHome, 'config.toml'), [ + 'model = "fixture-model"', + 'model_provider = "fixture"', + 'approval_policy = "on-request"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.fixture]', + 'name = "Fixture Responses"', + `base_url = "${responses.baseUrl}"`, + 'env_key = "OPENAI_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + }, + async inspect(cwd): Promise { + session = await onlySession(join(cwd, '.sessions')) + quiescence = JSON.parse(await readFile(join(cwd, '.codex-quiescence.json'), 'utf8')) + }, + }) + + expect(result.stderr).toBe('') + expect(session).toBeDefined() + if (session === undefined) throw new Error('Codex Loader snapshot session was not inspected') + const context: NormalizeContext = { + sessionIds: [session.header.id], + cwd: session.header.cwd, + } + const normalizedSession = scrubSystemPrompts(normalizeSessionLog(session.content, context)) + const request = responses.requests[0] + expect(request).toBeDefined() + if (request === undefined) throw new Error('Codex Loader snapshot made no Responses request') + const evidence = `${JSON.stringify({ + stdout: JSON.parse(normalizeStdout(result.stdout, context)) as unknown, + request: { + method: request.method, + path: request.path, + authorization: request.headers.authorization, + taskObserved: responseInputTexts(request.body) + .includes('Return the Loader snapshot sentinel exactly.'), + }, + quiescence, + }, null, 2)}\n` + + if (refreshing) { + await mkdir(snapshotDir, { recursive: true }) + await Promise.all([ + writeFile(sessionExpected, normalizedSession), + writeFile(evidenceExpected, evidence), + ]) + } + expect(normalizedSession).toBe(await readFile(sessionExpected, 'utf8')) + expect(evidence).toBe(await readFile(evidenceExpected, 'utf8')) + } finally { + await responses.close() + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS + 30_000) +}) diff --git a/examples/package.json b/examples/package.json index 849d8fc3ff..83e20a5b2b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,9 +63,11 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", + "@deepseek-ai/dsh-subagent-codex": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", diff --git a/knip.json b/knip.json index fd941b03ca..75909cc8d3 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,7 @@ "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", + "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", @@ -540,6 +541,18 @@ "tests/**/*.ts" ] }, + "packages/subagent/subagent-codex": { + "entry": [ + "tests/**/*.spec.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@openai/codex" + ] + }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 360db31ade..875a9c93a7 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/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/README.md -README.md: f9b04b4aa80b6feacf5d0d1fa4cf6b3b2aebc211 -README.zh.md: 0afc01a00ae9089f603531345c8a3ac4dd760326 +README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5 +README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd diff --git a/packages/subagent/README.md b/packages/subagent/README.md index f9b04b4aa8..abe1432d3c 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -11,11 +11,12 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | +| `subagent-codex/` | Out-of-process backend: a real Codex app-server process with one ephemeral thread and turn | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) | -The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 0afc01a00a..3df2b6c62d 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -11,11 +11,12 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | | `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | +| `subagent-codex/` | 进程外后端:一个真实的 Codex app-server 进程,包含一个临时 thread 和一个轮次 | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) | | `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) | -接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml new file mode 100644 index 0000000000..bee793e09a --- /dev/null +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md +README.md: ca92f935539812351dd578dca700c9a0113dcd46 +README.zh.md: 6f6690ea51970dd39c738ad0ec4f55c2a5ab2467 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md new file mode 100644 index 0000000000..ca92f93553 --- /dev/null +++ b/packages/subagent/subagent-codex/README.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-subagent-codex + +English | [中文](README.zh.md) + +This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. + +## Start and ownership + +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. + +The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. + +The unattended provider answers command and file approvals with `decline`, answers permission requests with an empty turn-scoped permission set, and declines MCP elicitation. Any other server request fails the run instead of waiting for interaction that this provider cannot supply. + +Local cancellation wins the result race and maps to `aborted`; a remote interrupted or failed turn maps to `error`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` when the current ids are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. + +## Capabilities and context + +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | +| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | + +Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. + +```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed +``` + +## Product compatibility and evidence + +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: package tests drive the real binary against a loopback Responses service with a non-empty fake key, and the Loader snapshot fixes the model-visible tool schema, exact tool result, persisted parent Session, original child task, authentication header, and pre-teardown process-tree quiescence. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. + +## Model Experience + +### Child request + +#### What the model sees + +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration. + +#### Token effect + +The child pays for an independent Codex context and turn. Child tokens do not enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Reuse depends only on Codex's own provider, model, instructions, tools, and ephemeral-thread request. + +### Parent tool result, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, the parent sees only the selected final Codex answer or the consumer's exact error for a non-completed result. Codex commentary, reasoning, tool activity, stderr, workspace diffs, and product ids are not copied into the parent Session. + +#### Token effect + +Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself. + +#### KV Cache effect + +Append-only: the new tool result follows the reusable parent request prefix. + +## Known Limitations and Deferred Work + +- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, and real-product tests. +- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. +- **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. +- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md new file mode 100644 index 0000000000..6f6690ea51 --- /dev/null +++ b/packages/subagent/subagent-codex/README.zh.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-subagent-codex + +[English](README.md) | 中文 + +本包(package)注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。 + +## 启动与所有权 + +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 + +已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 + +无人值守的提供方对命令与文件审批答复 `decline`,对权限请求返回作用域限于当前轮次的空权限集,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方无法提供的交互。 + +本地取消会在结果竞态中胜出并映射为 `aborted`;远端轮次若中断或失败,则映射为 `error`。`dispose()` 具有幂等性:如果当前标识符已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 + +## 能力与上下文 + +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | +| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | + +生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 + +```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed +``` + +## 产品兼容性与证据 + +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:包测试使用非空的伪密钥,驱动真实二进制程序连接回环 Responses 服务;Loader 快照则锁定模型可见的工具 schema、确切的工具结果、已持久化的父会话、原始子任务、身份验证请求头,以及清理前进程树的完全停稳状态。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 + +## 模型体验 + +### 子任务请求 + +#### 模型看到的内容 + +Codex 子任务会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。 + +#### 对 token 的影响 + +子任务需为独立的 Codex 上下文和轮次承担 token 开销。子任务 token 不会进入父级上下文。 + +#### 对 KV Cache 的影响 + +这与父请求缓存相互独立。能否复用只取决于 Codex 自身的提供方、模型、指令、工具和临时线程请求。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父级模型只会看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异和产品标识符均不会复制到父会话。 + +#### 对 token 的影响 + +父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。 + +#### 对 KV Cache 的影响 + +仅追加:新的工具结果接在可复用的父请求前缀之后。 + +## 已知限制与后续工作 + +- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 +- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消和真实产品测试。 +- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 +- **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json new file mode 100644 index 0000000000..ea4a2a2e45 --- /dev/null +++ b/packages/subagent/subagent-codex/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-subagent-codex", + "description": "One-shot Codex subagent provider over the official app-server protocol", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@openai/codex": "0.146.0", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts new file mode 100644 index 0000000000..00fe95d817 --- /dev/null +++ b/packages/subagent/subagent-codex/src/index.ts @@ -0,0 +1,89 @@ +/** + * Fixed Codex one-shot subagent provider. Every accepted run starts a fresh + * official `codex app-server --stdio` process in the delegating Session's + * workspace and publishes only after an ephemeral thread exists. + * + * @module @deepseek-ai/dsh-subagent-codex + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { + assertPositiveFinite, + NO_START_CAPABILITIES, + resolveChildCwd, + type ResolvedSubagentStartRequest, + type SubagentCapabilities, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { + DEFAULT_DISPOSE_GRACE_MS, + startCodexRun, + type CodexRunSpec, +} from './run.ts' + +export const name = 'subagent-codex' +export const inject = ['subagents', 'subprocess'] + +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for app-server process-tree termination. */ + disposeGraceMs?: number +} + +export const Config: z = z.object({ + env: z.dict(z.string()).default({}), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), +}) + +type ResolvedConfig = Required + +class CodexProvider implements SubagentProvider { + readonly name = 'codex' + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + + constructor( + private readonly ctx: Context, + private readonly config: ResolvedConfig, + ) {} + + start(request: ResolvedSubagentStartRequest) { + const spec: CodexRunSpec = { + cwd: resolveChildCwd( + 'subagent-codex', + undefined, + request.parent.session.header.cwd, + ), + env: this.config.env, + disposeGraceMs: this.config.disposeGraceMs, + spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), + onError: (error, stopReason) => { + this.ctx.logger.warn( + `subagent-codex: child run failed (${stopReason}): ${error.message}`, + ) + }, + } + return startCodexRun(request, spec) + } +} + +/** + * Register the fixed `codex` provider. + * @param ctx - context carrying shared subagent and subprocess services. + * @param config - explicit child environment and disposal grace. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite( + 'subagent-codex', + 'disposeGraceMs', + resolved.disposeGraceMs, + ) + ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) +} diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts new file mode 100644 index 0000000000..a0c094af9c --- /dev/null +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-codex`. + * @module @deepseek-ai/dsh-subagent-codex/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex' + +/** Cordis companion plugin name. */ +export const name = 'subagent-codex-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: lifecycle pairing belongs to the shared subagent + * service and process-tree ownership belongs to the subprocess service. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - plugin context carrying the invariant registry. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts new file mode 100644 index 0000000000..f58b0a6877 --- /dev/null +++ b/packages/subagent/subagent-codex/src/run.ts @@ -0,0 +1,209 @@ +/** + * One-shot Codex child lifecycle: spawn the real app-server through the + * subprocess seam, publish only after initialization and ephemeral thread + * creation, flatten post-publication failures, and dispose to whole-tree + * quiescence. + * + * @module @deepseek-ai/dsh-subagent-codex/run + */ + +import { randomUUID } from 'node:crypto' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + settleRunResult, + subprocessRunHandle, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, + type SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { CodexAppServerWire } from './wire.ts' + +/** Default POSIX grace between subprocess termination tiers. */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/** Fully resolved inputs for one Codex app-server run. */ +export interface CodexRunSpec { + /** Parent Session workspace, also supplied to `thread/start`. */ + readonly cwd: string + /** Explicit deployment/test environment layered after the shared scrub. */ + readonly env: Record + /** Subprocess termination grace and final tree-exit bound. */ + readonly disposeGraceMs: number + /** Shared subprocess service spawn operation. */ + readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle + /** Diagnostic sink for a post-publication error flattened into a result. */ + readonly onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Validate and preserve the one-shot task before crossing the process seam. + * @param prompt - task content accepted from the shared subagent service. + * @returns the exact non-empty text block sequence. + */ +export function textTask(prompt: readonly ContentBlock[]): string[] { + if (prompt.length === 0) { + throw new Error('subagent-codex: the one-shot task must contain only text blocks') + } + const texts: string[] = [] + for (const block of prompt) { + if (block.type !== 'text') { + throw new Error('subagent-codex: the one-shot task must contain only text blocks') + } + texts.push(block.text) + } + if (texts.every(text => text.trim().length === 0)) { + throw new Error('subagent-codex: the one-shot task must not be empty') + } + return texts +} + +async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, ms) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) + } +} + +/** + * Close the private wire, terminate the managed process tree, and wait for the + * subprocess owner to prove it is gone. + * @param wire - private app-server protocol connection. + * @param child - shared-service handle that owns the process tree. + * @param graceMs - termination grace used to bound final exit observation. + */ +export async function disposeCodexChild( + wire: CodexAppServerWire, + child: SubprocessHandle, + graceMs: number, +): Promise { + wire.close() + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } + try { + child.stdin?.end() + } catch { + // A concurrently closed stdin does not change tree ownership below. + } + child.terminate() + if (!(await treeExitsWithin(child, graceMs * 2))) { + throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') + } + await child.done +} + +/** + * Start the real `codex app-server --stdio` child and publish its one-shot run. + * @param request - resolved shared subagent request. + * @param spec - workspace, environment, process seam, and diagnostic policy. + * @returns the published run after initialization and ephemeral thread creation. + */ +export async function startCodexRun( + request: SubagentStartRequest, + spec: CodexRunSpec, +): Promise { + const texts = textTask(request.prompt) + if (request.signal.aborted) { + throw new Error('subagent-codex: request was aborted before app-server startup') + } + + const child = spec.spawn({ + argv: ['codex', 'app-server', '--stdio'], + cwd: spec.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: spec.disposeGraceMs, + env: spec.env, + }) + if (child.stdin === undefined || child.stdout === undefined) { + child.terminate() + await child.waitForExit() + throw new Error('subagent-codex: subprocess implementation dropped a piped protocol stream') + } + + const wire = new CodexAppServerWire(child.stdout, child.stdin) + const disposeProcess = (): Promise => + disposeCodexChild(wire, child, spec.disposeGraceMs) + + const processFailure: Promise = child.done.then( + outcome => Promise.reject(new Error( + 'subagent-codex: app-server exited before the run settled ' + + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, + )), + (error: unknown) => Promise.reject(thrown(error)), + ) + // A normal post-result dispose also closes the process. Keep that expected + // late rejection observed after the result race has already settled. + processFailure.catch(() => {}) + + const flags = { cancelled: false } + const runAbort = new AbortController() + let settleCancellation!: () => void + const cancellation = new Promise((resolve) => { settleCancellation = resolve }) + const requestCancel = (): void => { + if (flags.cancelled) return + flags.cancelled = true + runAbort.abort(new Error('subagent-codex: run cancelled locally')) + settleCancellation() + wire.interrupt() + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + try { + wire.start() + await Promise.race([wire.initialize(request.signal), processFailure]) + await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + try { + await disposeProcess() + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-codex: startup failed and app-server cleanup also failed', + ) + } + if (flags.cancelled) { + throw new Error('subagent-codex: request was aborted before app-server startup') + } + throw thrown(error) + } + + const collectOutput = (): ContentBlock[] => wire.collectOutput() + const result: Promise = settleRunResult({ + attempt: () => Promise.race([ + wire.runTurn(texts, runAbort.signal, () => flags.cancelled), + processFailure, + cancellation.then((): SubagentResult => ({ + output: collectOutput(), + stopReason: 'aborted', + })), + ]), + collectOutput, + cancelled: () => flags.cancelled, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: disposeProcess, + }) +} diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts new file mode 100644 index 0000000000..e8f743d1fa --- /dev/null +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -0,0 +1,366 @@ +/** + * Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC + * transport owns framing and request correlation; this module owns only the + * product methods, current thread/turn association, unattended approval + * responses, and terminal-answer selection. + * + * @module @deepseek-ai/dsh-subagent-codex/wire + */ + +import type { Readable, Writable } from 'node:stream' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult } from '@deepseek-ai/dsh-subagent' +import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' + +type JsonObject = Record + +interface Deferred { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +function object(value: unknown, label: string): JsonObject { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`subagent-codex: app-server returned invalid ${label}`) + } + return value as JsonObject +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`subagent-codex: app-server returned invalid ${label}`) + } + return value +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed protocol and stream failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`) +} + +async function raceAbort(pending: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + void pending.catch(() => {}) + throw abortError(signal) + } + let rejectAbort!: (error: Error) => void + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { rejectAbort(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([pending, aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +/** + * One app-server connection and its single ephemeral thread/turn. + * + * The class deliberately exposes no generic request surface. Supporting + * another product method must first become part of the provider contract. + */ +export class CodexAppServerWire { + private readonly transport: JsonRpcLineTransport + private readonly fatal = deferred() + private threadId: string | undefined + private turnId: string | undefined + private pendingTurnId: string | undefined + private turnCompleted: Deferred | undefined + private readonly earlyTurnNotifications: Array<{ + readonly method: string + readonly params: JsonObject + }> = [] + private readonly finalAnswers: string[] = [] + private readonly unphasedAnswers: string[] = [] + private started = false + private closed = false + + constructor( + private readonly input: Readable, + output: Writable, + ) { + this.transport = new JsonRpcLineTransport(input, output) + this.transport.onRequest((method, params) => this.handleServerRequest(method, params)) + this.transport.onNotification((method, params) => { + try { + this.handleNotification(method, params) + } catch (error: unknown) { + this.fail(thrown(error)) + } + }) + } + + /** Start reading app-server frames. */ + start(): void { + if (this.started) return + this.started = true + this.input.on('error', this.onInputError) + this.input.on('end', this.onInputEnd) + this.transport.start() + } + + /** + * Perform the required app-server initialize/initialized handshake. + * @param signal - unpublished-start cancellation. + */ + async initialize(signal: AbortSignal): Promise { + const response = object(await this.guarded(this.transport.request('initialize', { + clientInfo: { + name: 'deepseek-harness', + title: 'DeepSeek Harness', + version: '0.0.1', + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, + }, signal), signal), 'initialize response') + string(response.userAgent, 'initialize userAgent') + this.transport.notify('initialized') + await this.guarded(this.transport.flush(), signal) + } + + /** + * Create the run's private ephemeral thread and retain its identity. + * @param cwd - parent Session workspace. + * @param signal - unpublished-start cancellation. + * @returns the app-server thread id. + */ + async startThread(cwd: string, signal: AbortSignal): Promise { + const response = object(await this.guarded(this.transport.request('thread/start', { + cwd, + ephemeral: true, + }, signal), signal), 'thread/start response') + const thread = object(response.thread, 'thread/start thread') + const id = string(thread.id, 'thread/start thread id') + if (thread.ephemeral !== true) { + throw new Error('subagent-codex: app-server did not create an ephemeral thread') + } + this.threadId = id + return id + } + + /** + * Submit the one text-only task and wait for this thread/turn's authoritative + * terminal notification. + * @param texts - already validated task text blocks. + * @param signal - local cancellation for the published run. + * @param cancelled - whether local cancellation has already won. + * @returns the shared three-state subagent result. + */ + async runTurn( + texts: readonly string[], + signal: AbortSignal, + cancelled: () => boolean, + ): Promise { + if (this.threadId === undefined) { + throw new Error('subagent-codex: cannot start a turn before thread/start') + } + if (this.turnCompleted !== undefined) { + throw new Error('subagent-codex: this one-shot wire already started its turn') + } + const completion = deferred() + this.turnCompleted = completion + const response = object(await this.guarded(this.transport.request('turn/start', { + threadId: this.threadId, + input: texts.map(text => ({ type: 'text', text, text_elements: [] })), + }, signal), signal), 'turn/start response') + const turn = object(response.turn, 'turn/start turn') + this.commitTurnId(string(turn.id, 'turn/start turn id')) + + const completed = await this.guarded(completion.promise, signal) + if (cancelled()) return { output: this.collectOutput(), stopReason: 'aborted' } + + const terminal = object(completed.turn, 'turn/completed turn') + const status = terminal.status + if (status !== 'completed') { + const detail = status === 'failed' + ? `: ${JSON.stringify(terminal.error)}` + : '' + throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) + } + const output = this.collectOutput() + if (output.length === 0) { + throw new Error('subagent-codex: Codex completed without a final answer') + } + return { output, stopReason: 'completed' } + } + + /** + * Best-effort remote cancellation. Local settlement and process teardown + * remain authoritative when the child no longer accepts protocol requests. + */ + interrupt(): void { + if (this.threadId === undefined || this.turnId === undefined || this.closed) return + void this.transport.request('turn/interrupt', { + threadId: this.threadId, + turnId: this.turnId, + }).catch(() => {}) + } + + /** + * The best non-commentary answer observed so far, preserving exact bytes. + * @returns the selected final or nullable-phase text block, if any. + */ + collectOutput(): ContentBlock[] { + const selected = this.finalAnswers.length > 0 + ? this.finalAnswers.at(-1) + : this.unphasedAnswers.at(-1) + return selected !== undefined && selected.trim().length > 0 + ? [{ type: 'text', text: selected }] + : [] + } + + /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ + close(): void { + if (this.closed) return + this.closed = true + this.input.off('error', this.onInputError) + this.input.off('end', this.onInputEnd) + this.transport.close() + } + + private async guarded(pending: Promise, signal: AbortSignal): Promise { + const withFatal = Promise.race([ + pending, + this.fatal.promise.then((error): Promise => Promise.reject(error)), + ]) + return raceAbort(withFatal, signal) + } + + private fail(error: Error): void { + this.fatal.resolve(error) + } + + private readonly onInputError = (error: Error): void => { + this.fail(error) + } + + private readonly onInputEnd = (): void => { + this.fail(new Error('subagent-codex: app-server protocol stream closed')) + } + + private observePendingTurnId(id: string): void { + if (this.turnCompleted === undefined) { + throw new Error('subagent-codex: app-server referenced a turn before turn/start') + } + if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { + throw new Error('subagent-codex: app-server referenced conflicting turns') + } + this.pendingTurnId = id + } + + private commitTurnId(id: string): void { + if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { + throw new Error('subagent-codex: turn/start response did not match the active turn') + } + this.turnId = id + const notifications = this.earlyTurnNotifications.splice(0) + for (const notification of notifications) { + this.handleNotification(notification.method, notification.params) + } + } + + private validateRunIds(params: JsonObject, nullableTurn = false): void { + if (params.threadId !== this.threadId) { + throw new Error('subagent-codex: app-server request referenced another thread') + } + if (nullableTurn && params.turnId === null) return + const id = string(params.turnId, 'server request turn id') + if (this.turnId === undefined) { + this.observePendingTurnId(id) + return + } + if (id !== this.turnId) { + throw new Error('subagent-codex: app-server request referenced another turn') + } + } + + private handleServerRequest(method: string, params: JsonObject): Promise { + try { + switch (method) { + case 'item/commandExecution/requestApproval': + case 'item/fileChange/requestApproval': + this.validateRunIds(params) + return Promise.resolve({ decision: 'decline' }) + case 'item/permissions/requestApproval': + this.validateRunIds(params) + return Promise.resolve({ permissions: {}, scope: 'turn' }) + case 'mcpServer/elicitation/request': + this.validateRunIds(params, true) + return Promise.resolve({ action: 'decline', content: null, _meta: null }) + default: + throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`) + } + } catch (error: unknown) { + const normalized = thrown(error) + this.fail(normalized) + return Promise.reject(normalized) + } + } + + private handleNotification(method: string, params: JsonObject): void { + if (method === 'turn/started') { + if (params.threadId !== this.threadId) return + const turn = object(params.turn, 'turn/started turn') + if (this.turnCompleted !== undefined && this.turnId === undefined) { + this.observePendingTurnId(string(turn.id, 'turn/started turn id')) + } + return + } + if (method === 'item/completed') { + if (params.threadId !== this.threadId) return + const id = string(params.turnId, 'item/completed turn id') + if (this.turnId === undefined) { + if (this.turnCompleted !== undefined) { + this.observePendingTurnId(id) + this.earlyTurnNotifications.push({ method, params }) + } + return + } + if (id !== this.turnId) return + const item = object(params.item, 'item/completed item') + if (item.type !== 'agentMessage') return + const text = typeof item.text === 'string' + ? item.text + : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })() + if (item.phase === 'final_answer') { + this.finalAnswers.push(text) + } else if (item.phase === null) { + this.unphasedAnswers.push(text) + } else if (item.phase !== 'commentary') { + throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`) + } + return + } + if (method !== 'turn/completed') return + if (params.threadId !== this.threadId) return + const turn = object(params.turn, 'turn/completed turn') + const id = string(turn.id, 'turn/completed turn id') + const turnCompleted = this.turnCompleted + if (turnCompleted === undefined) return + if (this.turnId === undefined) { + this.observePendingTurnId(id) + this.earlyTurnNotifications.push({ method, params }) + return + } + if (id !== this.turnId) return + if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { + throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) + } + turnCompleted.resolve(params) + } +} diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts new file mode 100644 index 0000000000..77c494f762 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -0,0 +1,230 @@ +import { execFile } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import { + startResponsesFixture, + type ResponsesBehavior, + type ResponsesFixture, +} from './responses-fixture.ts' + +const execFileAsync = promisify(execFile) +const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackage = JSON.parse(readFileSync( + join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + 'utf8', +)) as { version: string } + +const roots: string[] = [] +const fixtures: ResponsesFixture[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +interface RealHarness { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly parent: Agent + readonly env: Record + readonly workspace: string +} + +async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ + readonly harness: RealHarness + readonly fixture: ResponsesFixture +}> { + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-')) + roots.push(root) + const workspace = join(root, 'workspace') + const codexHome = join(root, 'codex-home') + const fixture = await startResponsesFixture(script) + fixtures.push(fixture) + mkdirSync(workspace) + mkdirSync(codexHome) + writeFileSync(join(codexHome, 'config.toml'), [ + 'model = "fixture-model"', + 'model_provider = "fixture"', + 'approval_policy = "on-request"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.fixture]', + 'name = "Fixture Responses"', + `base_url = "${fixture.baseUrl}"`, + 'env_key = "OPENAI_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + const env = { + OPENAI_API_KEY: 'dsh-fake-openai-key', + CODEX_HOME: codexHome, + HOME: root, + XDG_CONFIG_HOME: join(root, 'xdg'), + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + const parent = { + id: 'real-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + return { harness: { ctx, handles, parent, env, workspace }, fixture } +} + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + const outcome = await handle.done + expect(outcome).toHaveProperty('exitCode') + expect(outcome).toHaveProperty('signal') + } +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +describe('real @openai/codex 0.146.0 product', () => { + it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { + const sentinel = 'REAL_CODEX_SENTINEL_0_146_0' + const task = 'Return the fixture sentinel exactly.' + const { harness, fixture } = await realHarness([ + { kind: 'complete', text: sentinel }, + ]) + expect(codexPackage.version).toBe('0.146.0') + const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + env: { ...process.env, ...harness.env }, + }) + expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: task }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(fixture.requests).toHaveLength(1) + const recorded = fixture.requests[0]! + expect(recorded.method).toBe('POST') + expect(recorded.path).toBe('/v1/responses') + expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key') + expect(responseInputTexts(recorded.body)).toContain(task) + await expectQuiescent(harness.handles) + }, 20_000) + + it('declines a real app-server command approval without executing the command', async () => { + const sentinel = 'REAL_CODEX_APPROVAL_DECLINED' + const { harness, fixture } = await realHarness([ + { + kind: 'functionCall', + name: 'exec_command', + arguments: { + cmd: 'touch approval-side-effect', + sandbox_permissions: 'require_escalated', + justification: 'exercise the unattended approval boundary', + }, + }, + { kind: 'complete', text: sentinel }, + ]) + const sideEffect = join(harness.workspace, 'approval-side-effect') + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Attempt the fixture command.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(existsSync(sideEffect)).toBe(false) + expect(fixture.requests).toHaveLength(2) + const tools = fixture.requests[0]!.body.tools as Array> + expect(tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'function', name: 'exec_command' }), + ])) + const followup = JSON.stringify(fixture.requests[1]!.body) + expect(followup).toContain('call_fixture') + expect(followup).toContain('rejected by user') + expect(fixture.requests.every(requestEntry => + requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', + )).toBe(true) + await expectQuiescent(harness.handles) + }, 20_000) + + it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const controller = new AbortController() + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Wait for cancellation.' }], + parent: harness.parent, + signal: controller.signal, + }) + await fixture.requestStarted + controller.abort(new Error('real product cancellation')) + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) + await run.dispose() + await expectQuiescent(harness.handles) + }, 20_000) +}) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts new file mode 100644 index 0000000000..940b0d52a0 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -0,0 +1,283 @@ +import { createServer } from 'node:http' +import type { + IncomingHttpHeaders, + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' + +/** One request observed by the package-private Responses fixture. */ +interface RecordedResponsesRequest { + readonly method: string | undefined + readonly path: string | undefined + readonly headers: IncomingHttpHeaders + readonly body: Record +} + +/** Behavior consumed by one Responses request. */ +export type ResponsesBehavior = + | { readonly kind: 'complete'; readonly text: string } + | { + readonly kind: 'functionCall' + readonly name: string + readonly arguments: Record + } + | { readonly kind: 'hold' } + +/** Running package-private Responses fixture. */ +export interface ResponsesFixture { + readonly baseUrl: string + readonly requests: RecordedResponsesRequest[] + readonly requestStarted: Promise + close(): Promise +} + +function responseObject(text: string): Record { + const message = { + id: 'msg_fixture', + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ + type: 'output_text', + annotations: [], + logprobs: [], + text, + }], + } + return { + id: 'resp_fixture', + object: 'response', + created_at: 1, + status: 'completed', + background: false, + error: null, + incomplete_details: null, + instructions: null, + max_output_tokens: null, + max_tool_calls: null, + model: 'fixture-model', + output: [message], + parallel_tool_calls: true, + previous_response_id: null, + prompt_cache_key: null, + prompt_cache_retention: null, + reasoning: { effort: null, summary: null }, + safety_identifier: null, + service_tier: 'default', + store: false, + temperature: null, + text: { format: { type: 'text' }, verbosity: 'medium' }, + tool_choice: 'auto', + tools: [], + top_logprobs: 0, + top_p: null, + truncation: 'disabled', + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 1, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 11, + }, + user: null, + metadata: {}, + } +} + +function completeEvents(text: string): Record[] { + const completed = responseObject(text) + const message = (completed.output as Record[])[0]! + const part = (message.content as Record[])[0]! + return [ + { + type: 'response.created', + response: { ...completed, status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...message, status: 'in_progress', content: [] }, + }, + { + type: 'response.content_part.added', + item_id: message.id, + output_index: 0, + content_index: 0, + part: { ...part, text: '' }, + }, + { + type: 'response.output_text.delta', + item_id: message.id, + output_index: 0, + content_index: 0, + delta: text, + logprobs: [], + }, + { + type: 'response.output_text.done', + item_id: message.id, + output_index: 0, + content_index: 0, + text, + logprobs: [], + }, + { + type: 'response.content_part.done', + item_id: message.id, + output_index: 0, + content_index: 0, + part, + }, + { + type: 'response.output_item.done', + output_index: 0, + item: message, + }, + { type: 'response.completed', response: completed }, + ] +} + +function functionCallEvents( + name: string, + argumentsValue: Record, +): Record[] { + const argumentsText = JSON.stringify(argumentsValue) + const item = { + id: 'fc_fixture', + type: 'function_call', + status: 'completed', + name, + arguments: argumentsText, + call_id: 'call_fixture', + } + const completed = { + ...responseObject(''), + output: [item], + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 15, + }, + } + return [ + { + type: 'response.created', + response: { ...completed, status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...item, status: 'in_progress', arguments: '' }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: item.id, + output_index: 0, + delta: argumentsText, + }, + { + type: 'response.function_call_arguments.done', + item_id: item.id, + output_index: 0, + arguments: argumentsText, + }, + { + type: 'response.output_item.done', + output_index: 0, + item, + }, + { type: 'response.completed', response: completed }, + ] +} + +function readRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { resolve(body) }) + request.on('error', reject) + }) +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + server.closeAllConnections() + }) +} + +/** + * Start a loopback-only Responses SSE fixture. + * @param script - one behavior per expected Responses request. + * @returns the running fixture and its observed requests. + */ +export async function startResponsesFixture( + script: readonly ResponsesBehavior[], +): Promise { + const behaviors = [...script] + const requests: RecordedResponsesRequest[] = [] + const started = Promise.withResolvers() + const openResponses = new Set() + const server = createServer((request, response) => { + openResponses.add(response) + response.on('close', () => { openResponses.delete(response) }) + void readRequest(request).then((body) => { + requests.push({ + method: request.method, + path: request.url, + headers: request.headers, + body: JSON.parse(body) as Record, + }) + started.resolve(undefined) + const behavior = behaviors.shift() + if (behavior === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } })) + return + } + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'x-request-id': 'req_fixture', + }) + if (behavior.kind === 'hold') return + const events = behavior.kind === 'complete' + ? completeEvents(behavior.text) + : functionCallEvents(behavior.name, behavior.arguments) + for (const event of events) { + response.write(`data: ${JSON.stringify(event)}\n\n`) + } + response.end('data: [DONE]\n\n') + }).catch((error: unknown) => { + response.destroy(error instanceof Error ? error : new Error(String(error))) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('responses fixture did not acquire a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + requests, + requestStarted: started.promise, + async close(): Promise { + for (const response of openResponses) response.destroy() + await closeServer(server) + }, + } +} diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts new file mode 100644 index 0000000000..6e6f3dbeaf --- /dev/null +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -0,0 +1,1053 @@ +import { PassThrough } from 'node:stream' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { + SubprocessHandle, + SubprocessOutcome, +} from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import * as invariant from '../src/invariant.ts' +import { + DEFAULT_DISPOSE_GRACE_MS, + disposeCodexChild, + startCodexRun, + textTask, + type CodexRunSpec, +} from '../src/run.ts' +import { CodexAppServerWire } from '../src/wire.ts' + +type JsonObject = Record + +const fakeParent = { + id: 'parent', + session: { header: { cwd: process.cwd() } }, +} as unknown as Agent + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }], + signal = new AbortController().signal, +) { + return { prompt, parent: fakeParent, signal } +} + +async function nextTask(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} + +class ProtocolPeer { + private buffer = '' + private readonly frames: JsonObject[] = [] + private readonly wakeups = new Set<() => void>() + + constructor( + input: PassThrough, + private readonly output: PassThrough, + ) { + input.on('data', (chunk: Buffer | string) => { + this.buffer += chunk.toString() + for (;;) { + const newline = this.buffer.indexOf('\n') + if (newline < 0) break + const line = this.buffer.slice(0, newline) + this.buffer = this.buffer.slice(newline + 1) + if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject) + } + for (const wake of this.wakeups) wake() + this.wakeups.clear() + }) + } + + async next(predicate: (frame: JsonObject) => boolean): Promise { + for (;;) { + const index = this.frames.findIndex(predicate) + if (index >= 0) return this.frames.splice(index, 1)[0]! + await new Promise((resolve) => { this.wakeups.add(resolve) }) + } + } + + nextMethod(method: string): Promise { + return this.next(frame => frame.method === method) + } + + nextResponse(id: unknown): Promise { + return this.next(frame => frame.id === id && frame.method === undefined) + } + + send(...frames: readonly JsonObject[]): void { + this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`) + } + + respond(requestFrame: JsonObject, result: unknown): void { + this.send({ id: requestFrame.id, result }) + } +} + +interface FakeChildOptions { + readonly pid?: number + readonly stdin?: boolean + readonly stdout?: boolean + readonly exitOnTerminate?: boolean + readonly waitForExitResult?: boolean + readonly doneError?: Error +} + +interface FakeChild { + readonly handle: SubprocessHandle + readonly peer: ProtocolPeer + readonly fromChild: PassThrough + readonly toChild: PassThrough + readonly settle: (outcome?: SubprocessOutcome) => void + readonly fail: (error: Error) => void + readonly terminate: () => void + readonly waitForExit: (signal?: AbortSignal) => Promise +} + +function fakeChild(options: FakeChildOptions = {}): FakeChild { + const fromChild = new PassThrough() + const toChild = new PassThrough() + const peer = new ProtocolPeer(toChild, fromChild) + let exited = false + let resolveDone!: (outcome: SubprocessOutcome) => void + let rejectDone!: (error: Error) => void + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + const settle = ( + outcome: SubprocessOutcome = { exitCode: 0, signal: null }, + ): void => { + if (exited) return + exited = true + resolveDone(outcome) + } + const fail = (error: Error): void => { + if (exited) return + exited = true + rejectDone(error) + } + if (options.doneError !== undefined) fail(options.doneError) + const terminate = vi.fn(() => { + if (options.exitOnTerminate !== false) settle() + }) + const waitForExit = vi.fn(async (signal?: AbortSignal) => { + if (options.waitForExitResult !== undefined) { + return options.waitForExitResult + } + if (exited) return true + if (signal === undefined) { + await done.catch(() => {}) + return true + } + return await new Promise((resolve) => { + const onAbort = (): void => { resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + void done.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + ) + }) + }) + const handle: SubprocessHandle = { + pid: options.pid ?? 1234, + stdin: options.stdin === false ? undefined : toChild, + stdout: options.stdout === false ? undefined : fromChild, + stderr: undefined, + collected: {}, + done, + terminate, + waitForExit, + } + return { + handle, + peer, + fromChild, + toChild, + settle, + fail, + terminate, + waitForExit, + } +} + +function runSpec( + child: FakeChild, + overrides: Partial = {}, +): CodexRunSpec { + return { + cwd: process.cwd(), + env: {}, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: () => child.handle, + ...overrides, + } +} + +async function initializeWire(): Promise<{ + readonly child: FakeChild + readonly wire: CodexAppServerWire +}> { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await initializing + expect(await child.peer.nextMethod('initialized')).toEqual({ + jsonrpc: '2.0', + method: 'initialized', + }) + const starting = wire.startThread(process.cwd(), new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await expect(starting).resolves.toBe('thread-1') + return { child, wire } +} + +async function publishRun( + child = fakeChild(), + signal = new AbortController().signal, + specOverrides: Partial = {}, +) { + const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + const turnStart = await child.peer.nextMethod('turn/start') + return { child, run, turnStart } +} + +function agentMessage( + text: unknown, + phase: unknown, + turnId = 'turn-1', + threadId = 'thread-1', +): JsonObject { + return { + method: 'item/completed', + params: { + threadId, + turnId, + item: { type: 'agentMessage', text, phase }, + }, + } +} + +function turnCompleted( + status: unknown, + turnId = 'turn-1', + threadId = 'thread-1', + error: unknown = null, +): JsonObject { + return { + method: 'turn/completed', + params: { + threadId, + turn: { id: turnId, status, error }, + }, + } +} + +describe('task admission and package contracts', () => { + it('accepts one or more text blocks and rejects empty or non-text tasks', () => { + expect(textTask([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ])).toEqual(['one', 'two']) + expect(() => textTask([])).toThrow('only text blocks') + expect(() => textTask([{ type: 'reasoning', text: 'hidden' }])) + .toThrow('only text blocks') + expect(() => textTask([{ type: 'text', text: ' \n ' }])) + .toThrow('must not be empty') + }) + + it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const fiber = await ctx.plugin(codex, {}) + const provider = ctx.subagents.getProvider('codex')! + expect(provider).toMatchObject({ + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['codex']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + + for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(ctx.plugin(codex, { disposeGraceMs })) + .rejects.toThrow('disposeGraceMs must be a positive finite number') + } + await ctx.fiber.dispose() + }) + + it('keeps the namespace export shape and package-owned empty invariant', async () => { + expect('default' in codex).toBe(false) + expect(codex.name).toBe('subagent-codex') + expect(codex.inject).toEqual(['subagents', 'subprocess']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(codex)).toBe(codex) + + const dispose = vi.fn() + const register = vi.fn(( + _packageName: string, + _installer: InvariantInstaller, + ) => dispose) + const ctx = { invariants: { register } } as unknown as Context + await expect(invariant.apply(ctx)).resolves.toBe(dispose) + expect(register).toHaveBeenCalledWith( + '@deepseek-ai/dsh-subagent-codex', + expect.any(Function), + ) + const install = register.mock.calls[0]![1] + await install(new Context(), (message) => { throw new Error(message) }) + expect(invariant.name).toBe('subagent-codex-invariant') + expect(invariant.inject).toEqual(['invariants']) + }) +}) + +describe('CodexAppServerWire', () => { + it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + expect(wire.collectOutput()).toEqual([]) + wire.start() + wire.start() + + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + expect(initialize.params).toEqual({ + clientInfo: { + name: 'deepseek-harness', + title: 'DeepSeek Harness', + version: '0.0.1', + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, + }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await initializing + await child.peer.nextMethod('initialized') + + const starting = wire.startThread('/workspace', new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true }) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await starting + + const result = wire.runTurn( + ['first', 'second'], + new AbortController().signal, + () => false, + ) + const turnStart = await child.peer.nextMethod('turn/start') + expect(turnStart.params).toEqual({ + threadId: 'thread-1', + input: [ + { type: 'text', text: 'first', text_elements: [] }, + { type: 'text', text: 'second', text_elements: [] }, + ], + }) + child.peer.send( + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, + }, + agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'), + agentMessage('other turn', 'final_answer', 'turn-2'), + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'reasoning', text: 'not output' }, + }, + }, + agentMessage('commentary', 'commentary'), + agentMessage('unphased', null), + agentMessage('first final', 'final_answer'), + agentMessage('last final', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'last final' }], + stopReason: 'completed', + }) + expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }]) + wire.close() + wire.close() + }) + + it('uses the last nullable-phase answer when no explicit final exists', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('first', null), + agentMessage('fallback', null), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'fallback' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('rejects invalid handshake, thread, and turn response shapes', async () => { + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + const frame = await child.peer.nextMethod('initialize') + child.peer.respond(frame, null) + await expect(pending).rejects.toThrow('invalid initialize response') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.startThread('/workspace', new AbortController().signal) + const frame = await child.peer.nextMethod('thread/start') + child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } }) + await expect(pending).rejects.toThrow('did not create an ephemeral thread') + wire.close() + } + { + const { child, wire } = await initializeWire() + const pending = wire.runTurn(['task'], new AbortController().signal, () => false) + const frame = await child.peer.nextMethod('turn/start') + child.peer.respond(frame, { turn: { id: '' } }) + await expect(pending).rejects.toThrow('turn/start turn id') + wire.close() + } + }) + + it('rejects a turn before thread publication and a second one-shot turn', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(wire.runTurn(['task'], new AbortController().signal, () => false)) + .rejects.toThrow('before thread/start') + const initialized = await initializeWire() + const first = initialized.wire.runTurn( + ['task'], + new AbortController().signal, + () => false, + ) + await initialized.child.peer.nextMethod('turn/start') + await expect(initialized.wire.runTurn( + ['again'], + new AbortController().signal, + () => false, + )).rejects.toThrow('already started') + initialized.wire.close() + await expect(first).rejects.toThrow('transport closed') + }) + + it('fails closed for empty output, malformed messages, phases, and terminal status', async () => { + const scenarios: Array<{ + readonly frames: JsonObject[] + readonly message: string + }> = [ + { + frames: [turnCompleted('completed')], + message: 'without a final answer', + }, + { + frames: [ + agentMessage('fallback', null), + agentMessage(' \n ', 'final_answer'), + turnCompleted('completed'), + ], + message: 'without a final answer', + }, + { + frames: [agentMessage(42, 'final_answer')], + message: 'invalid agent message', + }, + { + frames: [agentMessage('answer', 'future_phase')], + message: 'unknown agent message phase', + }, + { + frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })], + message: 'status failed', + }, + { + frames: [turnCompleted('interrupted')], + message: 'status interrupted', + }, + { + frames: [turnCompleted('inProgress')], + message: 'invalid terminal turn status', + }, + ] + for (const scenario of scenarios) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(...scenario.frames) + await expect(result).rejects.toThrow(scenario.message) + wire.close() + } + }) + + it('gives local cancellation precedence over a remote completed turn', async () => { + const { child, wire } = await initializeWire() + let cancelled = false + const result = wire.runTurn( + ['task'], + new AbortController().signal, + () => cancelled, + ) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + cancelled = true + child.peer.send(agentMessage('late', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'late' }], + stopReason: 'aborted', + }) + wire.close() + }) + + it('answers all four unattended request classes without granting authority', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + + child.peer.send({ + id: 'command', + method: 'item/commandExecution/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + expect(await child.peer.nextResponse('command')).toMatchObject({ + result: { decision: 'decline' }, + }) + + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + const requests = [ + { + id: 'file', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + result: { decision: 'decline' }, + }, + { + id: 'permissions', + method: 'item/permissions/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + result: { permissions: {}, scope: 'turn' }, + }, + { + id: 'mcp', + method: 'mcpServer/elicitation/request', + params: { threadId: 'thread-1', turnId: null }, + result: { action: 'decline', content: null, _meta: null }, + }, + ] as const + for (const serverRequest of requests) { + child.peer.send(serverRequest) + expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({ + result: serverRequest.result, + }) + } + + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + wire.close() + }) + + it('fails the run on unknown requests or wrong request association', async () => { + for (const serverRequest of [ + { + id: 'unknown', + method: 'item/tool/requestUserInput', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }, + { + id: 'thread', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-2', turnId: 'turn-1' }, + }, + { + id: 'turn', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-2' }, + }, + ]) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send(serverRequest) + const response = await child.peer.nextResponse(serverRequest.id) + expect(response.error).toMatchObject({ code: -32603 }) + await expect(result).rejects.toThrow() + wire.close() + } + }) + + it('rejects conflicting early turn identities before accepting output', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-early' } }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + wire.close() + }) + + it('rejects conflicting early notifications and requests before turn/start', async () => { + { + const { child, wire } = await initializeWire() + child.peer.send({ + id: 'too-early', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + const response = await child.peer.nextResponse('too-early') + expect(response.error).toMatchObject({ code: -32603 }) + wire.close() + } + { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + await child.peer.nextMethod('turn/start') + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, + }, + agentMessage('wrong', 'final_answer', 'turn-2'), + ) + await expect(result).rejects.toThrow('conflicting turns') + wire.close() + } + }) + + it('interrupts only an active open turn and contains remote interrupt failure', async () => { + const { child, wire } = await initializeWire() + wire.interrupt() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + wire.interrupt() + const interrupt = await child.peer.nextMethod('turn/interrupt') + expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' }) + child.peer.send({ + id: interrupt.id, + error: { code: -32000, message: 'already done' }, + }) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + wire.close() + wire.interrupt() + }) + + it('ignores unrelated and out-of-window notifications', async () => { + const { child, wire } = await initializeWire() + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-2', turn: { id: 'turn-other' } }, + }, + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-before' } }, + }, + agentMessage('before', 'final_answer'), + { method: 'future/notification', params: {} }, + turnCompleted('completed'), + turnCompleted('completed', 'turn-other', 'thread-2'), + ) + await nextTask() + + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send( + agentMessage('wrong turn', 'final_answer', 'turn-2'), + turnCompleted('completed', 'turn-2'), + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('rejects pending work on abort, EOF, and stream error', async () => { + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const controller = new AbortController() + controller.abort('pre-aborted') + await expect(wire.initialize(controller.signal)) + .rejects.toThrow('app-server request aborted: pre-aborted') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const controller = new AbortController() + const pending = wire.initialize(controller.signal) + await child.peer.nextMethod('initialize') + controller.abort(new Error('cancel initialize')) + await expect(pending).rejects.toThrow('cancel initialize') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.fromChild.end() + await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/) + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.fromChild.emit('error', new Error('stdout broke')) + await expect(pending).rejects.toThrow('stdout broke') + wire.close() + } + }) +}) + +describe('run lifecycle and quiescence', () => { + it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => { + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + const starting = startCodexRun( + request([{ type: 'text', text: 'task' }]), + runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }), + ) + let published = false + void starting.then(() => { published = true }) + const initialize = await child.peer.nextMethod('initialize') + expect(published).toBe(false) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + expect(published).toBe(false) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + expect(spawn).toHaveBeenCalledWith({ + argv: ['codex', 'app-server', '--stdio'], + cwd: process.cwd(), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: DEFAULT_DISPOSE_GRACE_MS, + env: { OPENAI_API_KEY: 'fake' }, + }) + expect(run.localAgent).toBeUndefined() + + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send( + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + const disposal = run.dispose() + expect(run.dispose()).toBe(disposal) + await disposal + await nextTask() + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + }) + + it('settles local cancellation immediately and sends best-effort interrupt', async () => { + const controller = new AbortController() + const { child, run, turnStart } = await publishRun( + fakeChild(), + controller.signal, + ) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + controller.abort(new Error('stop')) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({ + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + await run.dispose() + }) + + it('flattens child exit and protocol failures after publication', async () => { + const errors: string[] = [] + { + const child = fakeChild({ exitOnTerminate: false }) + const { run } = await publishRun(child, undefined, { + onError: (error) => { errors.push(error.message) }, + }) + child.settle({ exitCode: 9, signal: null }) + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + expect(errors.at(-1)).toContain('code 9') + await run.dispose().catch(() => {}) + } + { + const child = fakeChild() + const { run, turnStart } = await publishRun(child, undefined, { + onError: () => { throw new Error('diagnostic sink') }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.fromChild.end() + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + await run.dispose() + } + }) + + it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { + const controller = new AbortController() + controller.abort() + const spawn = vi.fn() + await expect(startCodexRun( + request(undefined, controller.signal), + { + cwd: process.cwd(), + env: {}, + disposeGraceMs: 10, + spawn, + }, + )).rejects.toThrow('aborted before app-server startup') + expect(spawn).not.toHaveBeenCalled() + + const child = fakeChild() + const starting = startCodexRun(request(), runSpec(child)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: '' }) + await expect(starting).rejects.toThrow('initialize userAgent') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('rolls back an abort that wins immediately after thread creation', async () => { + const controller = new AbortController() + const child = fakeChild() + const starting = startCodexRun( + request(undefined, controller.signal), + runSpec(child), + ) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + controller.abort('startup race') + await expect(starting).rejects.toThrow('aborted before app-server startup') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('rolls back a subprocess done rejection during startup', async () => { + const child = fakeChild({ doneError: new Error('spawn observer failed') }) + const error: unknown = await startCodexRun(request(), runSpec(child)).then( + () => undefined, + (failure: unknown) => failure, + ) + expect(error).toBeInstanceOf(AggregateError) + if (!(error instanceof AggregateError)) { + throw new Error('expected startup and rollback failures') + } + expect(error.errors).toEqual([ + expect.objectContaining({ message: 'spawn observer failed' }), + expect.objectContaining({ message: 'spawn observer failed' }), + ]) + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('reports both startup and rollback failures', async () => { + const child = fakeChild({ waitForExitResult: false, exitOnTerminate: false }) + const starting = startCodexRun( + request(), + runSpec(child, { disposeGraceMs: 1 }), + ) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: '' }) + await expect(starting).rejects.toThrow( + 'startup failed and app-server cleanup also failed', + ) + }) + + it('rejects a missing protocol stream after reaping the unpublished child', async () => { + for (const options of [{ stdin: false }, { stdout: false }]) { + const child = fakeChild(options) + await expect(startCodexRun(request(), runSpec(child))) + .rejects.toThrow('dropped a piped protocol stream') + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + } + }) + + it('keeps overlapping runs isolated', async () => { + const first = fakeChild() + const second = fakeChild() + const runs = await Promise.all([ + publishRun(first), + publishRun(second), + ]) + for (const [index, entry] of runs.entries()) { + const id = `turn-${index + 1}` + entry.child.peer.send( + { id: entry.turnStart.id, result: { turn: { id } } }, + agentMessage(`answer-${index + 1}`, 'final_answer', id), + turnCompleted('completed', id), + ) + } + const results = await Promise.all(runs.map(entry => entry.run.result)) + expect(results.map(result => result.output)).toEqual([ + [{ type: 'text', text: 'answer-1' }], + [{ type: 'text', text: 'answer-2' }], + ]) + expect(runs[0].run.id).not.toBe(runs[1].run.id) + await Promise.all(runs.map(entry => entry.run.dispose())) + }) + + it('uses the registered provider config and logs flattened errors', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const child = fakeChild() + const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { + warnings.push(String(message)) + }) as typeof ctx.logger.warn + await ctx.plugin(codex, { + env: { OPENAI_API_KEY: 'fake' }, + disposeGraceMs: 25, + }) + const starting = ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'task' }], + parent: fakeParent, + signal: new AbortController().signal, + }) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + await child.peer.nextMethod('turn/start') + child.settle({ exitCode: 1, signal: null }) + await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + env: { OPENAI_API_KEY: 'fake' }, + graceMs: 25, + cwd: process.cwd(), + })) + expect(warnings).toEqual([ + expect.stringContaining('subagent-codex: child run failed (error):'), + ]) + await run.dispose().catch(() => {}) + await ctx.fiber.dispose() + }) +}) + +describe('disposeCodexChild', () => { + it('closes stdin, terminates, and waits for the managed tree', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const end = vi.spyOn(child.toChild, 'end') + await disposeCodexChild(wire, child.handle, 100) + expect(end).toHaveBeenCalled() + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + }) + + it('contains a concurrently closed stdin error', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + vi.spyOn(child.toChild, 'end').mockImplementation(() => { + throw new Error('already closed') + }) + await expect(disposeCodexChild(wire, child.handle, 100)) + .resolves.toBeUndefined() + }) + + it('handles a spawn-level failure with no process tree', async () => { + const child = fakeChild({ + pid: -1, + doneError: new Error('spawn failed'), + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 100)) + .resolves.toBeUndefined() + expect(child.terminate).not.toHaveBeenCalled() + expect(child.waitForExit).not.toHaveBeenCalled() + }) + + it('fails when the tree misses the release window or done rejects', async () => { + { + const child = fakeChild({ + exitOnTerminate: false, + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 1)) + .rejects.toThrow('did not exit within its dispose window') + } + { + const child = fakeChild({ + doneError: new Error('close observer failed'), + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, 1)) + .rejects.toThrow('close observer failed') + } + { + const child = fakeChild() + const handle = { ...child.handle, stdin: undefined } + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, handle, 1)).resolves.toBeUndefined() + } + }) +}) diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json new file mode 100644 index 0000000000..6034bf5fbe --- /dev/null +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sdk/sdk-protocol" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index ceac4245a5..15873129cd 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/README.md -README.md: e54f0b98ec3649cec428a47026e6657a9749608b -README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4 +README.md: 4040f9a48bd61cc230adec1bd9725cf30bdfd8f7 +README.zh.md: 5f6a041887e3227d92a88eac344524e55a598413 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e54f0b98ec..4040f9a48b 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -14,6 +14,7 @@ The family separates the stable interface from implementations and model-facing | `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child; supports continuable children. | | `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | +| `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | | `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. | diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 1624fa5985..5f6a041887 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -14,6 +14,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | +| `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | | `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ca290fd6..237b8296c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:* + version: link:../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -721,6 +724,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../packages/subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../packages/subprocess/subprocess-local @@ -4948,6 +4954,43 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/subagent-codex: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@openai/codex': + specifier: 0.146.0 + version: 0.146.0 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subagent/subagent-dsh-sdk: dependencies: schemastery: @@ -7796,6 +7839,47 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@openai/codex@0.146.0': + resolution: {integrity: sha512-yG3sPWNda/2YAIQIDq9MrrjoCTIQ7rxYM5IasrG3VBcuhCLTkgeg/JzqmJq1V98RE4MJ5jCxDXXQlOjrditFRw==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.146.0-darwin-arm64': + resolution: {integrity: sha512-nb61yX4r5L6Z0dlC4o3u0GAK1YCd4TUvjaB382bajDoh84V+uv2hTBIVZ++fgXWV9yoeuNrNnNcn7GoTGOe2Tg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.146.0-darwin-x64': + resolution: {integrity: sha512-hTQR5jy/ObfTf1MDnuJCZJAe+SljKE8DDwQWN6lDFgjsPhMQz852U2tILt8Ei+G5GkQSzemHYKl2AYPwW0Y5xw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.146.0-linux-arm64': + resolution: {integrity: sha512-qiYDxkkEFnXG7joadJW6Q+XcgyDXCpGdpa9nk/c+i0gEomur1j7bHvx12NfWWCF/y8Tqri6ay+FLuC2MjdehtA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.146.0-linux-x64': + resolution: {integrity: sha512-fswvyGprAPCMiOEue/7MKMk7pCjh9kZIJfJX5i9atmfnmGYbYCcUhZsEH9LEP0+0t5xyPqDbfNXY7NSxIVuXxA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.146.0-win32-arm64': + resolution: {integrity: sha512-EW6zdjDe+SLX2Iw+xymJ5+Pz2+DGexdstfFHXh4Ub+TfJsQPiMjGfZfNaoWgdJ2FsqSIzVKu2+G0KCMGYz2W8g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.146.0-win32-x64': + resolution: {integrity: sha512-b3lxMYeR0+IhstNo4JjX1P9cPc1xwVcCVkPd1lD1wpWPJ0SBhpIkPczwbu3ZRkJcdyl342+rgyf4DUrbZLdrGA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -13131,6 +13215,33 @@ snapshots: '@nodable/entities@2.2.0': {} + '@openai/codex@0.146.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.146.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.146.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.146.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.146.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.146.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.146.0-win32-x64' + + '@openai/codex@0.146.0-darwin-arm64': + optional: true + + '@openai/codex@0.146.0-darwin-x64': + optional: true + + '@openai/codex@0.146.0-linux-arm64': + optional: true + + '@openai/codex@0.146.0-linux-x64': + optional: true + + '@openai/codex@0.146.0-win32-arm64': + optional: true + + '@openai/codex@0.146.0-win32-x64': + optional: true + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.0 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 36677e120e..804dbbddf4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'], - note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex'], + note: 'The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', @@ -416,7 +416,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider and continuation service', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, diff --git a/tsconfig.host.json b/tsconfig.host.json index bfc4f898e8..06af7e5871 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -194,6 +194,7 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/subagent/subagent-codex" }, { "path": "./packages/subagent/subagent-dsh-sdk" }, { "path": "./packages/tasks/tasks" }, { "path": "./packages/tasks/tasks-local" }, diff --git a/vitest.config.ts b/vitest.config.ts index ddf7741716..eac84d8d20 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -54,6 +54,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subagent/subagent-codex/tests/real-product.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', From 270b8e0acc64581f604a86367dd57ce5d6104d62 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 16:25:38 +0800 Subject: [PATCH 004/179] fix(subagent): close Codex provider CI gaps --- .../fixtures/subagent/subagent-codex/cordis.yml | 2 +- .../codex}/evidence.expected.json | 0 .../codex}/session.expected.jsonl | 0 .../tests/subagent-product-providers.snapshot.ts | 16 ++++++++++------ packages/subagent/subagent-codex/src/run.ts | 12 +----------- 5 files changed, 12 insertions(+), 18 deletions(-) rename examples/acp-agent/tests/{snapshots/subagent-codex => product-provider-snapshots/codex}/evidence.expected.json (100%) rename examples/acp-agent/tests/{snapshots/subagent-codex => product-provider-snapshots/codex}/session.expected.jsonl (100%) diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index cc50ea2587..f219214dd0 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -14,7 +14,7 @@ config: env: OPENAI_API_KEY: !!js process.env.DSH_TEST_OPENAI_API_KEY - CODEX_HOME: !!js process.cwd() + '/codex-home' + CODEX_HOME: !!js process.env.DSH_TEST_CODEX_HOME HOME: !!js process.cwd() XDG_CONFIG_HOME: !!js process.cwd() + '/xdg' PATH: !!js process.env.PATH diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json b/examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/subagent-codex/evidence.expected.json rename to examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json diff --git a/examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl b/examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/subagent-codex/session.expected.jsonl rename to examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl diff --git a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts index 6df5b7f411..64d1d244ae 100644 --- a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts +++ b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts @@ -5,9 +5,10 @@ * Code scenario and reruns both from its final stacked candidate. */ +import { homedir } from 'node:os' import { dirname, delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' import { normalizeSessionLog, @@ -25,7 +26,7 @@ const testsDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const fixtureDir = join(testsDir, 'fixtures/subagent/subagent-codex') const configPath = join(fixtureDir, 'cordis.yml') -const snapshotDir = join(testsDir, 'snapshots/subagent-codex') +const snapshotDir = join(testsDir, 'product-provider-snapshots/codex') const sessionExpected = join(snapshotDir, 'session.expected.jsonl') const evidenceExpected = join(snapshotDir, 'evidence.expected.json') const cliBin = join(repoRoot, 'packages/examples/cli-demo/src/bin.ts') @@ -75,6 +76,7 @@ function responseInputTexts(body: Record): string[] { describe('real product subagent providers through the Loader', () => { it('pins the Codex tool, result, persisted Session, and process quiescence', async () => { + const codexHome = await mkdtemp(join(homedir(), '.dsh-subagent-codex-loader-')) const responses = await startResponsesFixture([ { kind: 'complete', text: CODEX_SENTINEL }, ]) @@ -96,12 +98,11 @@ describe('real product subagent providers through the Loader', () => { tsconfigPath: repoTsconfig, processTimeoutMs: 45_000, env: { + DSH_TEST_CODEX_HOME: codexHome, DSH_TEST_OPENAI_API_KEY: FAKE_KEY, PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, }, - async prepare(cwd): Promise { - const codexHome = join(cwd, 'codex-home') - await mkdir(codexHome) + async prepare(): Promise { await writeFile(join(codexHome, 'config.toml'), [ 'model = "fixture-model"', 'model_provider = "fixture"', @@ -161,7 +162,10 @@ describe('real product subagent providers through the Loader', () => { expect(normalizedSession).toBe(await readFile(sessionExpected, 'utf8')) expect(evidence).toBe(await readFile(evidenceExpected, 'utf8')) } finally { - await responses.close() + await Promise.all([ + responses.close(), + rm(codexHome, { recursive: true, force: true }), + ]) } }, LOADER_SMOKE_TEST_TIMEOUT_MS + 30_000) }) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index f58b0a6877..39bd5bcf27 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -65,16 +65,6 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { return texts } -async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { - const controller = new AbortController() - const timer = setTimeout(() => { controller.abort() }, ms) - try { - return await child.waitForExit(controller.signal) - } finally { - clearTimeout(timer) - } -} - /** * Close the private wire, terminate the managed process tree, and wait for the * subprocess owner to prove it is gone. @@ -98,7 +88,7 @@ export async function disposeCodexChild( // A concurrently closed stdin does not change tree ownership below. } child.terminate() - if (!(await treeExitsWithin(child, graceMs * 2))) { + if (!(await child.waitForExit(AbortSignal.timeout(graceMs * 2)))) { throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') } await child.done From 9d65894314ac57b505083ee9bf666846ead0e6da Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 16:45:04 +0800 Subject: [PATCH 005/179] docs(agent-notes): replace product provider proposal --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 3 +- .../2026-06-21-subagent-capability-seam.zh.md | 3 +- .../2026-06-22-acp-subagent-backend.i18n.yaml | 4 +- .../2026-06-22-acp-subagent-backend.md | 2 +- .../2026-06-22-acp-subagent-backend.zh.md | 2 +- ...claude-code-and-codex-subagent-backends.md | 74 --------------- ...ude-code-and-codex-subagent-backends.zh.md | 74 --------------- ...ode-and-codex-subagent-backends.i18n.yaml} | 6 +- ...claude-code-and-codex-subagent-backends.md | 90 +++++++++++++++++++ ...ude-code-and-codex-subagent-backends.zh.md | 90 +++++++++++++++++++ 11 files changed, 193 insertions(+), 159 deletions(-) delete mode 100644 .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md delete mode 100644 .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md rename .agents/notes/proposed/feature/{2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml => 2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml} (54%) create mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md create mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index feb73d00dd..61640aade4 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 043092884731c403a11b71ef8b5a410e9e5af7e0 -2026-06-21-subagent-capability-seam.zh.md: 6a4a5798199ca7d6d7c011668a319d65a0553208 +2026-06-21-subagent-capability-seam.md: fd22b883572e5304c1587c818026c36235ef504d +2026-06-21-subagent-capability-seam.zh.md: fcfdf3e9c1eb35d7c372aa4311ebf8da192b56a7 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 0430928847..fd22b88357 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -14,7 +14,8 @@ The distinctive requirement — the one that shapes the whole design — is that - **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); -- later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend. +- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); +- later: **A2A** and the **Claude Code Agent SDK** — the same out-of-process "start a child, prompt it, settle, cancel" shape; the Claude sibling remains in the product-provider proposal. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 6a4a579819..fcfdf3e9c1 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -14,7 +14,8 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智 - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); -- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。 +- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); +- 后续:**A2A** 与 **Claude Code Agent SDK**——两者采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」;Claude 兄弟提供方仍在产品提供方提案中。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 774b9cc52e..207a8e7f6d 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md -2026-06-22-acp-subagent-backend.md: 5f12aa1c08d4f4cfaa35f2f7f4b09ad341c3eae8 -2026-06-22-acp-subagent-backend.zh.md: 61359246b0c552f6126cd82ae843d489de809a38 +2026-06-22-acp-subagent-backend.md: ea0ec821b1f7a55d173f58c5bc4ba8829ef65c54 +2026-06-22-acp-subagent-backend.zh.md: 32e83a7a4e89eb7adb17220dc66952bca0a165aa diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index 5f12aa1c08..ea0ec821b1 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -59,4 +59,4 @@ Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The par ## Future providers -The same out-of-process spawn/prompt/stream/cancel shape generalizes to other transports named in the seam Agent Note — A2A, the Codex app-server, and the Claude Code Agent SDK — each a sibling provider registered by name. The ACP backend is the proof that the seam supports the boundary; those are mechanically similar. +The [Codex app-server provider](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols. diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 61359246b0..32e83a7a4e 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -59,4 +59,4 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ## 后续提供方 -同样的进程外启动/提示词/流式输出/取消形态可泛化到 seam Agent Note 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 +[Codex app-server 提供方](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md deleted file mode 100644 index 86a2e3489a..0000000000 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Note: Claude Code and Codex subagent providers - -Status: proposed - -English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md) - -## Problem - -The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, use the parent Session's workspace, return a final answer or explicit failure, and leave no managed product process behind. - -Product integration must not create a second owner for task text, cwd, cancellation, result settlement, or process trees. It must also prove the real product path in required keyless tests: a fake wrapper or direct model HTTP request cannot establish that the Loader, provider registration, official product protocol, authentication, final answer, and teardown compose correctly. - -## Proposal - -Two sibling one-shot providers register fixed deployment names and are exposed through two fixed `dsh-tool-subagent` instances: - -- `@deepseek-ai/dsh-subagent-codex` registers `codex`, driven through `codex app-server --stdio`, and is implemented. -- `@deepseek-ai/dsh-subagent-claude-code` will register `claude-code`, driven through the official Claude Agent SDK and its bundled CLI, and remains pending. - -The model-facing tools are `subagent_codex` and `subagent_claude_code`. Each tool binds one provider at deployment time, accepts a standalone task, and omits the background parameter in the initial compositions. Product selection is not another model argument. - -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and use the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and one non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground disposal; the shared subprocess service owns environment scrubbing, process-tree termination, and whole-tree exit observation. - -## Codex provider - -The Codex provider has fixed name `codex` and fixed command `codex app-server --stdio`. Its public configuration contains only explicit `env` entries and a positive finite `disposeGraceMs`; it does not expose command, cwd, model, base URL, API key, sandbox, approval, product home, or session settings. Production resolves Codex from `PATH` and uses the host's native Codex configuration and authentication. Credential-shaped ambient variables are scrubbed by `dsh-subprocess`, while explicit `env` values merge afterward. - -Before publication, the provider validates a non-empty text-only task, starts the managed app-server, performs `initialize` → `initialized`, and creates an `ephemeral: true` thread in the parent workspace. The returned run owns exactly one `turn/start`; product thread and turn ids stay private and are not persisted in the parent Session. - -`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins, with the latest nullable-phase message as the compatibility fallback; commentary never replaces an answer. A completed turn without an answer, a failed or interrupted remote turn, malformed payload, protocol closure, early process exit, or unknown server request becomes a shared `error`. Local cancellation wins the race and remains `aborted`. - -The unattended wire declines command and file approvals, grants no requested permissions for the turn, and declines MCP elicitation. It fails closed for every other server request instead of waiting for UI that this provider does not supply. - -Publication transfers the wire and process handle to one holder. Idempotent disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. An unpublished startup failure performs the same cleanup before `start()` rejects. - -## Claude Code provider - -The Claude Code sibling follows the same fixed-name, self-contained, one-shot, parent-cwd, shared-result, and managed-tree boundaries. Its product-specific implementation will use the official Agent SDK's `query()` and spawn hook, keep SDK protocol ownership separate from `dsh-subprocess` process-tree ownership, omit human-interaction callbacks, and derive only a strict final SDK result after the message iterator ends normally. - -The Claude package will expose the same two configuration concerns, `env` and `disposeGraceMs`. Product installation, native settings, and login remain deployment responsibilities rather than plugin-managed state. This note stays proposed until that sibling and the combined two-product evidence are implemented. - -## Evidence contract - -Each product owns package-level branch-complete tests, a required real-product spec, and a real Loader snapshot. The real-product tier must use the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product configuration, and a loopback fixed-answer model; it fails rather than skips when the binary, authentication request, task, answer, cancellation, or process-exit proof is missing. - -The Codex evidence pins `@openai/codex@0.146.0` / `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and every managed handle reaching whole-tree quiescence. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, full persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still uses `codex` from `PATH`. - -## Alternatives considered - -**Direct model HTTP or `codex exec`.** These paths bypass the products' official extensible process protocols and cannot prove product configuration, tools, approvals, lifecycle, or teardown. The providers use app-server and the official Agent SDK instead. - -**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership before two production consumers demonstrated a missing common contract, so product-specific adapters call the existing seams directly. - -**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. - -**Product doubles as required evidence.** Doubles are useful for exhaustive private protocol branches but do not prove package exports, official binaries, authentication, or real process behavior. Required evidence drives the official product against loopback model fixtures. - -**Plugin-managed login, product home, models, or permissions.** Those settings would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only explicit environment overlay and teardown grace; unattended interaction fails closed. - -**Continuation, progress, and shared parent context.** The first user result needs one self-contained task and one final answer. Product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and background collection need separate user contracts and are not prebuilt. - -## Acceptance criteria - -The proposal is complete when both fixed tools reach their corresponding real products through the Loader, return exact final answers or explicit failure/cancellation, persist the complete model-visible parent transcript, and prove managed process-tree quiescence in required keyless CI. Both packages have complete configuration, lifecycle, failure, model-experience, and limitation documentation; the generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. - -The implemented Codex half already satisfies this contract for its fixed tool and 0.146.0 product baseline. The note remains proposed because the Claude Code sibling and combined final evidence are not yet implemented. - -## Risks - -- The Codex app-server protocol is product-versioned and may change; production performs no runtime version probe, so every supported baseline change must refresh schema investigation and real-product compatibility evidence. -- Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's own authority. -- Every delegation pays for a fresh process and independent model context, and only final text reaches the parent. -- Product tool or file side effects are not rolled back when a run fails or is cancelled. -- Unattended approval denial keeps the initial provider safe from interactive hangs but cannot satisfy tasks that require new permission. diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md deleted file mode 100644 index ef2098b3af..0000000000 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ /dev/null @@ -1,74 +0,0 @@ -# Agent Note: Claude Code 与 Codex subagent 提供方 - -Status: proposed - -[English](2026-07-07-claude-code-and-codex-subagent-backends.md) | 中文 - -## 问题 - -命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子 agent 的运行方式即可委派工作,但 harness 需要接入真实 Codex 与 Claude Code 产品的第一方路径。一个实用的首版必须能把一个自包含任务交给任一产品,使用父会话的工作区,返回最终答案或明确失败,并且不留下任何受管产品进程。 - -产品集成不得让任务文本、工作目录、取消、结果结算或进程树出现第二个所有者。它还必须在强制无密钥测试中证明真实产品链路:假包装层或直接向模型发起的 HTTP 请求无法证明 Loader、提供方注册、官方产品协议、认证、最终答案和清理能够正确组合运行。 - -## 提案 - -两个同级的单次执行提供方注册固定部署名称,并通过两个固定的 `dsh-tool-subagent` 实例对外提供: - -- `@deepseek-ai/dsh-subagent-codex` 注册 `codex`,由 `codex app-server --stdio` 驱动,现已实现。 -- `@deepseek-ai/dsh-subagent-claude-code` 将注册 `claude-code`,由官方 Claude Agent SDK 及其捆绑的 CLI(命令行界面)驱动,目前仍待实现。 - -面向模型的工具为 `subagent_codex` 和 `subagent_claude_code`。每个工具在部署时绑定一个提供方,接受一个独立任务,并在初始组合中省略后台参数。产品选择不作为额外的模型参数。 - -两个提供方均报告 `inheritsParentContext: false`,不声明任何可选启动能力,并使用父会话的工作目录而不复制父会话对话。每次调用都会创建一个全新的产品进程和一次不可恢复的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台 dispose(资源释放);共享子进程服务负责环境清洗、进程树终止和整棵进程树的退出观测。 - -## Codex 提供方 - -Codex 提供方的固定名称为 `codex`,固定命令为 `codex app-server --stdio`。其公开配置只包含显式 `env` 条目和取正有限值的 `disposeGraceMs`;不公开命令、工作目录、模型、基础 URL、API 密钥、沙箱、审批、产品主目录或会话设置。生产环境从 `PATH` 解析 Codex,并使用宿主机原生的 Codex 配置和认证。`dsh-subprocess` 会清洗环境中形似凭证的变量,之后再合并显式 `env` 值。 - -在发布运行实例前,提供方会验证任务非空且仅含文本,启动受管 app-server,依次执行 `initialize` → `initialized`,并在父工作区中创建一个 `ephemeral: true` 线程。返回的运行实例只负责一次 `turn/start`;产品线程 ID 和轮次 ID 始终为私有信息,不会持久化到父会话中。 - -`turn/completed` 是判定远端终止状态的权威依据。最新一条内容非空且带有 `phase: "final_answer"` 的 `agentMessage` 优先;阶段字段可为空值的最新消息作为兼容回退。过程说明绝不取代答案。已完成但无答案的轮次、失败或中断的远端轮次、格式错误的载荷、协议关闭、进程提前退出或未知服务端请求,都会结算为共享的 `error`。本地取消会在竞态中胜出,结果仍为 `aborted`。 - -无人值守通信层会拒绝命令审批和文件审批,对于该轮次请求的权限一概不予授予,并拒绝 MCP elicitation。对于其他所有服务端请求,它都会以失败响应,而不会等待本提供方并未提供的 UI。 - -发布时,协议连接和进程句柄会移交给唯一持有者。幂等 dispose 会尽力中断已知轮次、关闭协议连接、结束 stdin、调用共享的逐级终止流程,并等待整棵进程树退出。若启动在发布前失败,`start()` 会先执行同样的清理,再以拒绝结束。 - -## Claude Code 提供方 - -Claude Code 同级提供方沿用相同边界:名称固定、任务自包含、仅执行一次、使用父级工作目录、结果由共享服务结算,且进程树受管。其产品专用实现将使用官方 Agent SDK 的 `query()` 与 spawn 钩子,将 SDK 协议所有权同 `dsh-subprocess` 的进程树所有权分开,不设置人机交互回调,并且仅在消息迭代器正常结束后提取严格的最终 SDK 结果。 - -Claude 包将公开相同的两个配置项:`env` 和 `disposeGraceMs`。产品安装、原生设置和登录仍由部署方负责,插件不管理这些内容。在该同级提供方及两种产品的组合证据实现之前,本文仍处于 proposed 状态。 - -## 证据契约 - -每个产品都有包(package)级分支完备测试、一项必需的真实产品规格测试,以及一份真实 Loader 快照。真实产品层必须使用受测的确切官方发行包、非空的假产品密钥、隔离的临时工作区与产品配置,以及固定答案的环回模型;如果缺少二进制文件、认证请求、任务、答案、取消或进程退出证明中的任一项,该层必须失败而非跳过。 - -Codex 证据固定使用 `@openai/codex@0.146.0` / `codex-cli 0.146.0`。其真实产品规格测试会观测确切的 Bearer 密钥、原始任务、字节完全一致的最终答案、无人值守下命令被拒绝且不产生文件副作用、本地取消,以及每个受管句柄对应的整棵进程树均达到完全停稳。其 Loader 快照固定记录不含后台参数的工具 schema、确切的工具调用与工具结果、完整持久化的父会话、产品请求,以及清理前的完全停稳状态。该 npm 包是用于提供可复现证据的开发依赖;生产环境仍使用 `PATH` 中的 `codex`。 - -## 曾考虑的替代方案 - -**直接向模型发起 HTTP 请求或 `codex exec`。** 这些路径会绕过产品官方的可扩展进程协议,无法证明产品配置、工具、审批、生命周期或清理。提供方改用 app-server 和官方 Agent SDK。 - -**共享产品进程辅助包。** 现有 subagent seam 和子进程 seam 已经负责所有共享任务、结果、环境和进程树关注点。在两个生产消费方证明通用契约确有缺口之前,新辅助包会造成所有权重复,因此产品专用适配器直接调用现有 seam。 - -**面向模型的产品选择器。** 产品可用性与认证属于部署事实。两个固定工具让各自的 schema 和提供方绑定保持显式,并避免向通用服务加入动态选择状态。 - -**将产品替身作为必需证据。** 替身适合完整覆盖私有协议分支,但无法证明包导出、官方二进制文件、认证或真实进程行为。必需证据使用环回模型 fixture(测试前置数据)驱动官方产品。 - -**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外另立一个管理权威,并把单次执行提供方变成账户管理功能。提供方只公开显式环境叠加和清理宽限期;无人值守交互一律以失败响应。 - -**续接、进度与共享父级上下文。** 首版面向用户的功能只需接收一个自包含任务,并返回一个最终答案。产品会话、恢复、后续请求、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集各自需要独立的用户契约,本提案不会预先构建这些内容。 - -## 验收标准 - -当两个固定工具都能通过 Loader 接入各自的真实产品,返回精确的最终答案或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制无密钥 CI 中证明受管进程树完全停稳时,本提案即告完成。两个包都具备覆盖配置、生命周期、失败、模型体验与限制的完整文档;生成的包记录、配置记录、能力记录、依赖记录和第三方记录均与已发布的 manifest(元数据清单)一致。 - -已实现的 Codex 部分已经针对其固定工具和 0.146.0 产品基线满足此契约。本文仍处于 proposed 状态,因为 Claude Code 同级提供方和两种产品的最终组合证据尚未实现。 - -## 风险 - -- Codex app-server 协议随产品版本演进,可能发生变化;生产环境不执行运行时版本探测,因此每次变更受支持的基线时,都必须重新开展 schema 调查并更新真实产品兼容性证据。 -- 产品原生配置使行为取决于部署环境中安装的产品及其账户状态。强制测试会隔离这些输入,而生产环境则刻意让这些输入继续由产品自身掌控。 -- 每次委派都要承担启动全新进程和使用独立模型上下文的成本,而且只有最终文本会传回父 agent。 -- 运行失败或被取消时,产品工具或文件副作用不会回滚。 -- 无人值守模式下拒绝审批可防止初始提供方因交互而挂起,但无法满足需要新权限的任务。 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml similarity index 54% rename from .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml rename to .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 27fb29dffd..49028bf8b2 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.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 .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md -2026-07-07-claude-code-and-codex-subagent-backends.md: 86a2e3489a84408e24c6c8091bc52b747b9069b9 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: ef2098b3afe3e5602ed93de1984c91a5c4c1e79e +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +2026-08-04-claude-code-and-codex-subagent-backends.md: f8f9e10231874b6f97eba98594c952a86efd6901 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 60437a1bc6d8d3c3c9677b4a466ce6a7122fa4fc diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md new file mode 100644 index 0000000000..f8f9e10231 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -0,0 +1,90 @@ +# Agent Note: Claude Code and Codex subagent backends + +Status: proposed + +English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) + +## Problem + +The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. + +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. They must also prove the real assembled path in required keyless tests. A direct model HTTP request, product double, or hand-mounted plugin cannot show that the Loader, fixed tool, provider registration, official product protocol, native authentication shape, final answer, and teardown work together. + +## Proposal + +The harness provides two sibling one-shot providers behind two fixed model-facing tools. `subagent_codex` selects the `codex` provider, and `subagent_claude_code` selects the `claude-code` provider. Each tool accepts only a standalone text task and binds its provider at deployment time; product selection and background execution are not model arguments. + +The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains part of this proposal and will use Claude Agent SDK 0.3.220 with its bundled Claude Code 2.1.220 CLI. This Note remains proposed until both siblings and their combined evidence are present. + +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. + +```text +fixed tool → shared subagent service → product provider → official product process + ← final answer / explicit error / cancellation ← terminal product fact + → foreground disposal → shared process-tree termination → whole-tree exit +``` + +### Ownership and lifecycle + +| Phase | Shared owner | Product-specific responsibility | Observable result | +| --- | --- | --- | --- | +| Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published | +| Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects | +| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive one shared `completed`, `error`, or `aborted` result | The parent receives only a final answer or an explicit failure | +| Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits | + +## Codex provider + +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. + +Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. + +`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A completed turn without an answer, a failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`. Local cancellation wins its race and remains `aborted`. + +The unattended wire declines command and file approvals, grants no requested permissions for the turn, and declines MCP elicitation. Any other server request fails the run instead of waiting for a user interface the provider does not supply. + +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. + +## Claude Code provider + +The Claude Code sibling follows the same fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries. It will call the official Agent SDK's `query()` and use its `spawnClaudeCodeProcess` hook to pass the SDK-provided command, arguments, cwd, environment, and forwarded signal into `dsh-subprocess` without rewriting them. + +The SDK will continue to own the Claude protocol and graceful `Query.close()` intent, while `dsh-subprocess` owns the actual CLI process tree and exit proof. Publication waits until both the SDK query and real CLI handle are controllable. A strict successful `SDKResultMessage` becomes `completed` only after asynchronous iteration ends normally; local cancellation becomes `aborted`, and every other result, iterator failure, protocol failure, or process failure becomes `error`. + +The package will expose the same two configuration concerns, `env` and `disposeGraceMs`. It will keep native settings and login under Claude Code's authority, disable unattended `AskUserQuestion`, omit interactive callbacks, and create no plugin-owned product session or account state. + +## Evidence contract + +Each product owns branch-complete package tests, a required real-product spec, and a real Loader snapshot. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. + +The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, complete persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still supplies `codex` on `PATH`. + +The combined contract is complete only when the Claude sibling has equivalent real SDK and bundled-CLI evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract. + +## Alternatives considered + +**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible process protocols and cannot prove native configuration, tools, approvals, result semantics, or teardown. The providers use app-server and the official Agent SDK instead. + +**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership before the two products demonstrate a missing common contract, so each private adapter calls the existing seams directly. + +**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. + +**Product doubles as required evidence.** Doubles are useful for exhaustive private protocol branches but do not prove package exports, official binaries, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. + +**Plugin-managed login, product home, models, or permissions.** Those settings would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. + +**Continuation, progress, background collection, and shared parent context.** The first user result needs one self-contained task and one final answer. Product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and background collection need separate user contracts and are not prebuilt. + +## Acceptance criteria + +Both fixed tools reach their corresponding real products through the Loader, return exact final answers or explicit failure or cancellation, persist the complete model-visible parent transcript, and prove managed process-tree quiescence in required keyless CI. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. + +The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. + +## Risks + +- The product protocols are versioned and may change. Production performs no runtime version probe, so every supported baseline change requires refreshed compatibility evidence. +- Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's authority. +- Every delegation pays for a fresh process and independent model context, and only final text reaches the parent. +- Product tool or file side effects are not rolled back when a run fails or is cancelled. +- Unattended interaction denial prevents hidden approval hangs but cannot satisfy tasks that require new permission or human input. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md new file mode 100644 index 0000000000..60437a1bc6 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -0,0 +1,90 @@ +# Agent Note: Claude Code 与 Codex subagent 后端 + +Status: proposed + +[English](2026-08-04-claude-code-and-codex-subagent-backends.md) | 中文 + +## 问题 + +命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 + +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。它们还必须在强制性的无密钥测试中证明真实组装路径。直接发起模型 HTTP 请求、使用产品替身或手工挂载插件,都无法证明 Loader、固定工具、提供方注册、官方产品协议、原生身份验证形态、最终回答和资源清理能够协同工作。 + +## 提案 + +harness 在两个固定的面向模型工具背后提供两个一次性兄弟提供方。`subagent_codex` 选择 `codex` 提供方,`subagent_claude_code` 选择 `claude-code` 提供方。每个工具只接受独立文本任务,并在部署时绑定其提供方;产品选择与后台执行都不作为模型参数。 + +Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍属于本提案的一部分,将使用 Claude Agent SDK 0.3.220 及其捆绑的 Claude Code 2.1.220 CLI(命令行界面)。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 + +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 + +```text +fixed tool → shared subagent service → product provider → official product process + ← final answer / explicit error / cancellation ← terminal product fact + → foreground disposal → shared process-tree termination → whole-tree exit +``` + +### 归属与生命周期 + +| 阶段 | 共享责任方 | 产品特定职责 | 可观察结果 | +| --- | --- | --- | --- | +| 解析 | `dsh-tool-subagent` 与 `ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 | +| 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 | +| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导一个共享的 `completed`、`error` 或 `aborted` 结果 | 父级只会收到最终回答或明确失败 | +| dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 | + +## Codex 提供方 + +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 + +发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 + +`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。轮次完成却没有答案、远端轮次失败或中断、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`。本地取消在竞态中胜出并保持为 `aborted`。 + +无人值守的协议连接会拒绝命令与文件审批,不授予该轮次请求的任何权限,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方没有提供的用户界面。 + +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 + +## Claude Code 提供方 + +Claude Code 兄弟提供方遵循同样的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界。它将调用官方 Agent SDK 的 `query()`,并使用其 `spawnClaudeCodeProcess` 钩子,将 SDK 提供的命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`。 + +SDK 将继续负责 Claude 协议,并通过 `Query.close()` 表达优雅关闭意图;`dsh-subprocess` 则负责实际的 CLI 进程树与退出证明。只有在 SDK query 和真实 CLI 句柄均可控后才会发布运行。只有严格表示成功的 `SDKResultMessage` 才会在异步迭代正常结束后成为 `completed`;本地取消成为 `aborted`,其他任何结果、迭代器失败、协议失败或进程失败都成为 `error`。 + +该包会公开相同的两个配置项:`env` 与 `disposeGraceMs`。它会继续让 Claude Code 负责原生设置与登录,禁用无人值守的 `AskUserQuestion`,不提供交互式回调,也不会创建由插件负责的产品会话或账户状态。 + +## 证据契约 + +每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一个真实 Loader 快照。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。 + +Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader 快照锁定不支持后台执行的工具 schema、确切的工具调用与结果、完整的已持久化父会话、产品请求,以及清理前的完全停稳状态。该 NPM 包是用于复现证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 + +只有在 Claude 兄弟提供方具备同等的真实 SDK 与捆绑 CLI 证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。 + +## 曾考虑的替代方案 + +**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展进程协议,无法证明原生配置、工具、审批、结果语义或资源清理。提供方改为使用 app-server 与官方 Agent SDK。 + +**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。在两个产品尚未证明通用契约存在缺口时,新辅助包只会造成责任重复,因此各自的私有适配器会直接调用现有 seam。 + +**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 + +**以产品替身作为强制证据。** 替身有助于穷尽覆盖私有协议分支,但无法证明包导出、官方二进制程序、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 + +**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 + +**续接、进度、后台收集和共享父级上下文。** 首个用户结果只需要一项自包含任务和一个最终回答。产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集都需要独立的用户契约,本提案不会预先构建这些功能。 + +## 验收标准 + +两个固定工具都通过 Loader 到达相应的真实产品,返回完全一致的最终回答或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制性的无密钥 CI 中证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 + +已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 + +## 风险 + +- 产品协议受版本约束,且可能发生变化。生产环境不会执行运行时版本探测,因此每次更改受支持的基线都必须刷新兼容性证据。 +- 产品原生配置使行为取决于部署环境中安装的产品与账户状态。强制测试会隔离这些输入,而生产环境会有意让产品继续负责它们。 +- 每次委派都要承担新建进程和独立模型上下文的开销,且只有最终文本会到达父级。 +- 运行失败或被取消时,产品工具或文件产生的副作用不会回滚。 +- 拒绝无人值守交互可以防止审批流程暗中挂起,但无法完成需要新权限或人工输入的任务。 From 047ea509873aa2b891deb701924c19d7b35ec107 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:46:52 -0700 Subject: [PATCH 006/179] feat(web): add preview badge to empty hero --- .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/plan-active.expected.md | 2 +- .../ui-conversation/src/client/locales.ts | 2 ++ .../src/client/skeleton/EmptyHero.tsx | 3 +- .../src/client/skeleton/HeroShell.module.css | 31 ++++++++++++++++--- .../ui-conversation/tests/skeleton.spec.tsx | 11 ++++++- 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 8611ac5c0d..bdb07876a3 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building +- text: Let's start building Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index a9fb7901d7..8c5cf915dc 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building +- text: Let's start building Preview - button "Choose workspace": - img - text: workspace diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 7a38aaa0f8..2f05223e5d 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -33,6 +33,7 @@ export const zh = { 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', 'hero.headline': '开始构建吧', + 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', 'details.title': '详情', @@ -144,6 +145,7 @@ export const en = { 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', 'hero.headline': 'Let\'s start building', + 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', 'details.title': 'Details', diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 0c1b31bbb7..4b491e1f7f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -119,7 +119,8 @@ export function HeroShell({ t, children }: HeroShellProps) {
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} - {t('hero.headline')} + {t('hero.headline')} + {t('hero.preview')}
{/* The resident composer (ConversationRoot wrapActiveBody seat; the diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 523f640c0b..a95007e381 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -23,21 +23,44 @@ overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */ +/* Fish + title stay centered as one unit; the preview badge aligns with the + title's leading edge on the second row. */ .headline { - display: flex; + display: grid; + grid-template-columns: 34px auto; + column-gap: 10px; + row-gap: 4px; align-items: center; justify-content: center; - gap: 10px; font-size: 26px; line-height: 32px; font-weight: 500; color: var(--dsw-alias-label-primary); } +.headlineText { + grid-row: 1; + grid-column: 2; +} + +.previewBadge { + grid-row: 2; + grid-column: 2; + justify-self: start; + padding: 0 4px; + border-radius: 4px; + background: var(--dsw-alias-state-business-tertiary); + color: var(--dsw-alias-state-business-primary); + font-size: 12px; + line-height: 18px; + font-weight: 500; + white-space: nowrap; +} + /* figma fish fill rides business blue. */ .fish { - flex: none; + grid-row: 1; + grid-column: 1; color: var(--dsw-alias-state-business-primary); } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 312ec88642..506984bca4 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -12,12 +12,14 @@ import type { import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { SessionInputShell } from '../src/client/input/facade.ts' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx' import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx' +import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import type { @@ -214,6 +216,12 @@ function mount( } describe('ConversationRoot resident composer', () => { + it('renders the English preview badge through the hero locale seat', () => { + const view = render() + expect(view.getByText('Let\'s start building')).toBeTruthy() + expect(view.getByText('Preview')).toBeTruthy() + }) + it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => { const b = mount(conversationSnapshot()) const box = b.view.getByRole('textbox') @@ -273,6 +281,7 @@ describe('ConversationRoot resident composer', () => { expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') expect(b.view.getByText('开始构建吧')).toBeTruthy() + expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the // persistence mirror stays bound (ConversationSession mounts chrome-hidden From 36b562e4e95da11ce6996595826d37c870c368fc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 17:13:38 +0800 Subject: [PATCH 007/179] fix(subagent): close Codex provider review findings --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 12 ++--- ...ude-code-and-codex-subagent-backends.zh.md | 12 ++--- packages/subagent/subagent-codex/src/run.ts | 27 +++------- packages/subagent/subagent-codex/src/wire.ts | 37 +++++++------- .../tests/subagent-codex.spec.ts | 49 +++++-------------- 6 files changed, 48 insertions(+), 93 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 49028bf8b2..8431b6bbce 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: f8f9e10231874b6f97eba98594c952a86efd6901 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 60437a1bc6d8d3c3c9677b4a466ce6a7122fa4fc +2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index f8f9e10231..37f45f844c 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -14,7 +14,7 @@ The product integrations must not become second owners for task text, cwd, cance The harness provides two sibling one-shot providers behind two fixed model-facing tools. `subagent_codex` selects the `codex` provider, and `subagent_claude_code` selects the `claude-code` provider. Each tool accepts only a standalone text task and binds its provider at deployment time; product selection and background execution are not model arguments. -The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains part of this proposal and will use Claude Agent SDK 0.3.220 with its bundled Claude Code 2.1.220 CLI. This Note remains proposed until both siblings and their combined evidence are present. +The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -47,11 +47,7 @@ An unpublished startup failure closes the wire, terminates the acquired process ## Claude Code provider -The Claude Code sibling follows the same fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries. It will call the official Agent SDK's `query()` and use its `spawnClaudeCodeProcess` hook to pass the SDK-provided command, arguments, cwd, environment, and forwarded signal into `dsh-subprocess` without rewriting them. - -The SDK will continue to own the Claude protocol and graceful `Query.close()` intent, while `dsh-subprocess` owns the actual CLI process tree and exit proof. Publication waits until both the SDK query and real CLI handle are controllable. A strict successful `SDKResultMessage` becomes `completed` only after asynchronous iteration ends normally; local cancellation becomes `aborted`, and every other result, iterator failure, protocol failure, or process failure becomes `error`. - -The package will expose the same two configuration concerns, `env` and `disposeGraceMs`. It will keep native settings and login under Claude Code's authority, disable unattended `AskUserQuestion`, omit interactive callbacks, and create no plugin-owned product session or account state. +The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented. ## Evidence contract @@ -59,11 +55,11 @@ Each product owns branch-complete package tests, a required real-product spec, a The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, complete persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still supplies `codex` on `PATH`. -The combined contract is complete only when the Claude sibling has equivalent real SDK and bundled-CLI evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract. +The combined contract is complete only when the Claude sibling has equivalent real-product evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract. ## Alternatives considered -**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible process protocols and cannot prove native configuration, tools, approvals, result semantics, or teardown. The providers use app-server and the official Agent SDK instead. +**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. **A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership before the two products demonstrate a missing common contract, so each private adapter calls the existing seams directly. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 60437a1bc6..dcd7b90231 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -14,7 +14,7 @@ Status: proposed harness 在两个固定的面向模型工具背后提供两个一次性兄弟提供方。`subagent_codex` 选择 `codex` 提供方,`subagent_claude_code` 选择 `claude-code` 提供方。每个工具只接受独立文本任务,并在部署时绑定其提供方;产品选择与后台执行都不作为模型参数。 -Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍属于本提案的一部分,将使用 Claude Agent SDK 0.3.220 及其捆绑的 Claude Code 2.1.220 CLI(命令行界面)。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 +Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -47,11 +47,7 @@ fixed tool → shared subagent service → product provider → official product ## Claude Code 提供方 -Claude Code 兄弟提供方遵循同样的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界。它将调用官方 Agent SDK 的 `query()`,并使用其 `spawnClaudeCodeProcess` 钩子,将 SDK 提供的命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`。 - -SDK 将继续负责 Claude 协议,并通过 `Query.close()` 表达优雅关闭意图;`dsh-subprocess` 则负责实际的 CLI 进程树与退出证明。只有在 SDK query 和真实 CLI 句柄均可控后才会发布运行。只有严格表示成功的 `SDKResultMessage` 才会在异步迭代正常结束后成为 `completed`;本地取消成为 `aborted`,其他任何结果、迭代器失败、协议失败或进程失败都成为 `error`。 - -该包会公开相同的两个配置项:`env` 与 `disposeGraceMs`。它会继续让 Claude Code 负责原生设置与登录,禁用无人值守的 `AskUserQuestion`,不提供交互式回调,也不会创建由插件负责的产品会话或账户状态。 +Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界,本 Agent Note 才能进入 implemented 状态。 ## 证据契约 @@ -59,11 +55,11 @@ SDK 将继续负责 Claude 协议,并通过 `Query.close()` 表达优雅关闭 Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader 快照锁定不支持后台执行的工具 schema、确切的工具调用与结果、完整的已持久化父会话、产品请求,以及清理前的完全停稳状态。该 NPM 包是用于复现证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 -只有在 Claude 兄弟提供方具备同等的真实 SDK 与捆绑 CLI 证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。 +只有在 Claude 兄弟提供方具备同等的真实产品证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。 ## 曾考虑的替代方案 -**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展进程协议,无法证明原生配置、工具、审批、结果语义或资源清理。提供方改为使用 app-server 与官方 Agent SDK。 +**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展接入面,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都使用对应产品的官方接入方式。 **共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。在两个产品尚未证明通用契约存在缺口时,新辅助包只会造成责任重复,因此各自的私有适配器会直接调用现有 seam。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 39bd5bcf27..21f22d8a1b 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -116,13 +116,11 @@ export async function startCodexRun( graceMs: spec.disposeGraceMs, env: spec.env, }) - if (child.stdin === undefined || child.stdout === undefined) { - child.terminate() - await child.waitForExit() - throw new Error('subagent-codex: subprocess implementation dropped a piped protocol stream') - } - const wire = new CodexAppServerWire(child.stdout, child.stdin) + const wire = new CodexAppServerWire( + child.stdout as NonNullable, + child.stdin as NonNullable, + ) const disposeProcess = (): Promise => disposeCodexChild(wire, child, spec.disposeGraceMs) @@ -137,15 +135,10 @@ export async function startCodexRun( // late rejection observed after the result race has already settled. processFailure.catch(() => {}) - const flags = { cancelled: false } const runAbort = new AbortController() - let settleCancellation!: () => void - const cancellation = new Promise((resolve) => { settleCancellation = resolve }) const requestCancel = (): void => { - if (flags.cancelled) return - flags.cancelled = true + if (runAbort.signal.aborted) return runAbort.abort(new Error('subagent-codex: run cancelled locally')) - settleCancellation() wire.interrupt() } const onAbort = (): void => { requestCancel() } @@ -165,7 +158,7 @@ export async function startCodexRun( 'subagent-codex: startup failed and app-server cleanup also failed', ) } - if (flags.cancelled) { + if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before app-server startup') } throw thrown(error) @@ -174,15 +167,11 @@ export async function startCodexRun( const collectOutput = (): ContentBlock[] => wire.collectOutput() const result: Promise = settleRunResult({ attempt: () => Promise.race([ - wire.runTurn(texts, runAbort.signal, () => flags.cancelled), + wire.runTurn(texts, runAbort.signal, () => runAbort.signal.aborted), processFailure, - cancellation.then((): SubagentResult => ({ - output: collectOutput(), - stopReason: 'aborted', - })), ]), collectOutput, - cancelled: () => flags.cancelled, + cancelled: () => runAbort.signal.aborted, onError: spec.onError, signal: request.signal, onAbort, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index e8f743d1fa..9d15f7c6d1 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -83,9 +83,8 @@ export class CodexAppServerWire { readonly method: string readonly params: JsonObject }> = [] - private readonly finalAnswers: string[] = [] - private readonly unphasedAnswers: string[] = [] - private started = false + private lastFinalAnswer: string | undefined + private lastUnphasedAnswer: string | undefined private closed = false constructor( @@ -101,14 +100,16 @@ export class CodexAppServerWire { this.fail(thrown(error)) } }) + this.input.on('error', this.onInputError) + this.input.on('end', this.onInputEnd) + // Pipe errors can race protocol closure and process teardown. Retain both + // error listeners for the lifetime of their per-run streams so no late + // EPIPE or read failure becomes an unhandled EventEmitter error. + output.on('error', this.onOutputError) } /** Start reading app-server frames. */ start(): void { - if (this.started) return - this.started = true - this.input.on('error', this.onInputError) - this.input.on('end', this.onInputEnd) this.transport.start() } @@ -166,16 +167,11 @@ export class CodexAppServerWire { signal: AbortSignal, cancelled: () => boolean, ): Promise { - if (this.threadId === undefined) { - throw new Error('subagent-codex: cannot start a turn before thread/start') - } - if (this.turnCompleted !== undefined) { - throw new Error('subagent-codex: this one-shot wire already started its turn') - } const completion = deferred() this.turnCompleted = completion + const threadId = this.threadId as string const response = object(await this.guarded(this.transport.request('turn/start', { - threadId: this.threadId, + threadId, input: texts.map(text => ({ type: 'text', text, text_elements: [] })), }, signal), signal), 'turn/start response') const turn = object(response.turn, 'turn/start turn') @@ -216,9 +212,7 @@ export class CodexAppServerWire { * @returns the selected final or nullable-phase text block, if any. */ collectOutput(): ContentBlock[] { - const selected = this.finalAnswers.length > 0 - ? this.finalAnswers.at(-1) - : this.unphasedAnswers.at(-1) + const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer return selected !== undefined && selected.trim().length > 0 ? [{ type: 'text', text: selected }] : [] @@ -228,7 +222,6 @@ export class CodexAppServerWire { close(): void { if (this.closed) return this.closed = true - this.input.off('error', this.onInputError) this.input.off('end', this.onInputEnd) this.transport.close() } @@ -249,6 +242,10 @@ export class CodexAppServerWire { this.fail(error) } + private readonly onOutputError = (error: Error): void => { + this.fail(error) + } + private readonly onInputEnd = (): void => { this.fail(new Error('subagent-codex: app-server protocol stream closed')) } @@ -338,9 +335,9 @@ export class CodexAppServerWire { ? item.text : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })() if (item.phase === 'final_answer') { - this.finalAnswers.push(text) + this.lastFinalAnswer = text } else if (item.phase === null) { - this.unphasedAnswers.push(text) + this.lastUnphasedAnswer = text } else if (item.phase !== 'commentary') { throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 6e6f3dbeaf..66d5ef5d5c 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -90,8 +90,6 @@ class ProtocolPeer { interface FakeChildOptions { readonly pid?: number - readonly stdin?: boolean - readonly stdout?: boolean readonly exitOnTerminate?: boolean readonly waitForExitResult?: boolean readonly doneError?: Error @@ -161,8 +159,8 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { }) const handle: SubprocessHandle = { pid: options.pid ?? 1234, - stdin: options.stdin === false ? undefined : toChild, - stdout: options.stdout === false ? undefined : fromChild, + stdin: toChild, + stdout: fromChild, stderr: undefined, collected: {}, done, @@ -334,7 +332,6 @@ describe('CodexAppServerWire', () => { const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) expect(wire.collectOutput()).toEqual([]) wire.start() - wire.start() const initializing = wire.initialize(new AbortController().signal) const initialize = await child.peer.nextMethod('initialize') @@ -451,27 +448,6 @@ describe('CodexAppServerWire', () => { } }) - it('rejects a turn before thread publication and a second one-shot turn', async () => { - const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(wire.runTurn(['task'], new AbortController().signal, () => false)) - .rejects.toThrow('before thread/start') - const initialized = await initializeWire() - const first = initialized.wire.runTurn( - ['task'], - new AbortController().signal, - () => false, - ) - await initialized.child.peer.nextMethod('turn/start') - await expect(initialized.wire.runTurn( - ['again'], - new AbortController().signal, - () => false, - )).rejects.toThrow('already started') - initialized.wire.close() - await expect(first).rejects.toThrow('transport closed') - }) - it('fails closed for empty output, malformed messages, phases, and terminal status', async () => { const scenarios: Array<{ readonly frames: JsonObject[] @@ -757,6 +733,17 @@ describe('CodexAppServerWire', () => { await expect(pending).rejects.toThrow('stdout broke') wire.close() } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.toChild.emit('error', new Error('stdin broke')) + await expect(pending).rejects.toThrow('stdin broke') + wire.close() + child.toChild.emit('error', new Error('late stdin close')) + } }) }) @@ -918,16 +905,6 @@ describe('run lifecycle and quiescence', () => { ) }) - it('rejects a missing protocol stream after reaping the unpublished child', async () => { - for (const options of [{ stdin: false }, { stdout: false }]) { - const child = fakeChild(options) - await expect(startCodexRun(request(), runSpec(child))) - .rejects.toThrow('dropped a piped protocol stream') - expect(child.terminate).toHaveBeenCalledTimes(1) - expect(child.waitForExit).toHaveBeenCalledTimes(1) - } - }) - it('keeps overlapping runs isolated', async () => { const first = fakeChild() const second = fakeChild() From d7ee0e798a8b9d90d7b7addb1233885739727146 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 17:34:19 +0800 Subject: [PATCH 008/179] feat(subagent): mount Codex provider in shipped CLI --- ...-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...6-08-04-claude-code-and-codex-subagent-backends.md | 4 +++- ...8-04-claude-code-and-codex-subagent-backends.zh.md | 4 +++- apps/cli/composition.md | 6 ++++++ apps/cli/config/base.cordis.yml | 11 +++++++++++ apps/cli/package.json | 1 + apps/cli/tests/built-bin.e2e.ts | 6 ++++++ pnpm-lock.yaml | 3 +++ 8 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 8431b6bbce..91abd43031 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28 +2026-08-04-claude-code-and-codex-subagent-backends.md: 52181edba816f651877eac2c616cf67191f7e550 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: bde24eb8939b10c10916dab93effd3b94cbc7e1b diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 37f45f844c..52181edba8 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -16,7 +16,7 @@ The harness provides two sibling one-shot providers behind two fixed model-facin The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their fixed tools use `maxDepth: 'provider-managed'` because each out-of-process product owns any delegation budget inside its own harness; the parent sends no recursion cap that the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text fixed tool → shared subagent service → product provider → official product process @@ -37,6 +37,8 @@ fixed tool → shared subagent service → product provider → official product `@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. +The shipped `apps/cli/config/base.cordis.yml` loads this provider and a fixed `subagent_codex` tool by default, while `apps/cli/package.json` carries the provider package in the CLI dependency closure. Loading the base does not probe the Codex binary or authentication; missing native availability fails only when the tool is called. + Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. `turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A completed turn without an answer, a failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`. Local cancellation wins its race and remains `aborted`. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index dcd7b90231..bde24eb893 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -16,7 +16,7 @@ harness 在两个固定的面向模型工具背后提供两个一次性兄弟提 Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。固定工具使用 `maxDepth: 'provider-managed'`,因为每个进程外产品都负责其自身 harness 内部的委派预算;父级不会传入提供方无法执行的递归上限。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text fixed tool → shared subagent service → product provider → official product process @@ -37,6 +37,8 @@ fixed tool → shared subagent service → product provider → official product `@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 +正式发布的 `apps/cli/config/base.cordis.yml` 默认加载这个提供方和固定的 `subagent_codex` 工具,而 `apps/cli/package.json` 将提供方包纳入 CLI 依赖闭包。加载基础配置时不会探测 Codex 二进制程序或身份验证;缺少原生可用条件只会在工具实际调用时失败。 + 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 `turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。轮次完成却没有答案、远端轮次失败或中断、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`。本地取消在竞态中胜出并保持为 `aborted`。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0bede25716..a24fbd20f6 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,6 +94,8 @@ flowchart LR cfg --> plugin_dsh_base_subagent_spawn plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_dsh_base_subagent_fork + plugin_dsh_base_subagent_codex["subagent-codex
@deepseek-ai/dsh-subagent-codex"] + cfg --> plugin_dsh_base_subagent_codex plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] @@ -102,6 +104,8 @@ flowchart LR cfg --> plugin_dsh_base_tool_subagent plugin_dsh_base_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_dsh_base_tool_subagent_fork + plugin_dsh_base_tool_subagent_codex["tool-subagent-codex
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_dsh_base_tool_subagent_codex plugin_dsh_base_tool_subagent_report["tool-subagent-report
@deepseek-ai/dsh-tool-subagent-report"] cfg --> plugin_dsh_base_tool_subagent_report plugin_dsh_base_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] @@ -191,10 +195,12 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-codex` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 623b4d1153..5bc053c8c8 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -254,6 +254,9 @@ config: providerName: fork +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control @@ -276,6 +279,14 @@ toolName: subagent_fork backgroundMode: continuable +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: 'provider-managed' + # Optional direct-child return channel; absent from roots and one-shot agents. - id: tool-subagent-report name: '@deepseek-ai/dsh-tool-subagent-report' diff --git a/apps/cli/package.json b/apps/cli/package.json index 0ccf4b2197..b69fb30a73 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -98,6 +98,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-codex": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index fcfe8b3829..5e3d6442d2 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -85,6 +85,7 @@ function startRawLifecycle(fixture: RawLifecycleFixture) { env: { DSH_HOME: fixture.home, DSH_TELEMETRY_DISABLED: '1', + PATH: fixture.home, RAW_READY_FILE: fixture.ready, RAW_SETTLED_FILE: fixture.settled, RAW_DISPOSED_FILE: fixture.disposed, @@ -163,6 +164,11 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") expect(stdout).toContain('agents: []') expect(stdout).toContain('# == base.cordis.yml') + expect(stdout).toContain("name: '@deepseek-ai/dsh-subagent-codex'") + expect(stdout).toContain('provider: codex') + expect(stdout).toContain('toolName: subagent_codex') + expect(stdout).toContain('enableRunInBackground: false') + expect(stdout).toContain('maxDepth: provider-managed') }, 30_000) it('composes the required raw overlay directly over the base', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 237b8296c4..d85ed96bf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,6 +377,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork From de96087923ce63b967fa6bd64ff4de598f1151d2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 17:44:18 +0800 Subject: [PATCH 009/179] feat(web): name context sources and mark steering in the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every logged non-user user/message collapsed into one identical 「上下文注入」 row, and mid-turn steering rendered in exactly the bubble a turn-opening prompt uses, so the transcript could not say what had been added or which message interrupted a running turn. TranscriptAdapter and the history fold now attach a provenance view to each context node, computed by contextProvenance() from the durable source alone: a role (inject, or recall for a cross-session snapshot) and a producer name read out of the log — instruction paths for workspace-instructions, session titles for session-reference, the plugin id for a plugin source, and the bare kind for any other. No client-side table of producer names, so a renamed or newly mounted producer stays identifiable without a client release and a foreign log projects like a live one. ContextInjectionRow titles itself from the role and shows the name beside it; MessageItem captions durable and pending steering bubbles. The caption reverses one clause of the no-interjection-chrome decision, which removed it because the composer could not steer; composer steering shipped afterwards without amending that note, so this change supplies the product decision its reintroduction clause required and corrects the stale facts left in it. Fixes #1291 --- ...b-context-source-and-steer-marks.i18n.yaml | 6 ++ ...8-04-web-context-source-and-steer-marks.md | 48 +++++++++ ...4-web-context-source-and-steer-marks.zh.md | 48 +++++++++ ...eer-entry-or-interjection-chrome.i18n.yaml | 4 +- ...i-no-steer-entry-or-interjection-chrome.md | 1 + ...o-steer-entry-or-interjection-chrome.zh.md | 1 + apps/web/tests/seeded-history.e2e.ts | 12 ++- .../snapshots/bash-abort-row/ui.expected.md | 4 +- .../snapshots/code-mode-round/ui.expected.md | 4 +- .../cordis-tool-round/ui.expected.md | 4 +- .../snapshots/fresh-round-trip/ui.expected.md | 4 +- .../lifecycle-chrome/reloaded.expected.md | 4 +- .../live-interactions/cancel.expected.md | 4 +- .../live-interactions/error-auth.expected.md | 4 +- .../live-interactions/loading.expected.md | 4 +- .../live-interactions/retry.expected.md | 4 +- .../plan-review/approved.expected.md | 4 +- .../question-composer/answered.expected.md | 4 +- .../queue-actions/collapsed.expected.md | 4 +- .../queue-actions/editing.expected.md | 4 +- .../queue-actions/layout.expected.md | 12 +-- .../queue-actions/preserved.expected.md | 4 +- .../snapshots/queue-actions/ui.expected.md | 4 +- .../seeded-history/command-row.expected.md | 4 +- .../snapshots/seeded-history/ui.expected.md | 4 +- .../snapshots/steering/mid-steer.expected.md | 6 +- .../snapshots/steering/settled.expected.md | 6 +- .../subagent-conversation/ui.expected.md | 4 +- .../snapshots/web-search-round/ui.expected.md | 4 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/src/client/index.ts | 1 + .../client/session-history/history-fold.ts | 2 + .../src/client/sessions/context-provenance.ts | 91 ++++++++++++++++++ .../src/client/sessions/conversation.ts | 3 + .../src/client/sessions/transcript-adapter.ts | 2 + .../runtime/tests/context-provenance.spec.ts | Bin 0 -> 3196 bytes .../runtime/tests/transcript-adapter.spec.ts | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../chat/ContextInjectionRow.module.css | 14 +++ .../src/client/chat/ContextInjectionRow.tsx | 16 ++- .../src/client/chat/MessageItem.module.css | 9 ++ .../src/client/chat/MessageItem.tsx | 14 ++- .../ui-conversation/src/client/locales.ts | 4 + .../tests/chat-branch-tails.spec.tsx | 52 +++++++++- .../ui-conversation/tests/chat-view.spec.tsx | 5 + .../tests/context-branches.spec.ts | 1 + 50 files changed, 380 insertions(+), 74 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md create mode 100644 packages/client/runtime/src/client/sessions/context-provenance.ts create mode 100644 packages/client/runtime/tests/context-provenance.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml new file mode 100644 index 0000000000..c7695cfd3f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.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-04-web-context-source-and-steer-marks.md +2026-08-04-web-context-source-and-steer-marks.md: 5d158039b742af58a91b0e9d194b98e89485d8d7 +2026-08-04-web-context-source-and-steer-marks.zh.md: 46f5285033671407c1d26794cfd4ba74c06a8ad9 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md new file mode 100644 index 0000000000..5d158039b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -0,0 +1,48 @@ +# Agent Note: Web transcript marks context source, recall, and steering + +Status: implemented + +English | [中文](2026-08-04-web-context-source-and-steer-marks.zh.md) + +## Problem + +Everything a producer adds to the model-facing conversation reached the Web transcript as one of two anonymous shapes. Every logged non-user `user/message` — the skill catalog, the runtime snapshot, reconciled `AGENTS.md` instructions, a guard notice, a subagent report, a cross-session snapshot — collapsed into one identical `上下文注入` row, so a reader could not tell what had been added without expanding each row and reading raw JSON. Mid-turn steering was worse: it rendered in exactly the bubble a turn-opening prompt uses, leaving the transcript unable to say which message interrupted a running turn. + +The distinctions are already durable. `user/message.source` is the merge-extensible provenance every producer must supply, and steering has its own `steering/message` event; only the presentation discarded them. The terminal transcript this Web UI replaced did name each card's producer, so the Web surface was a regression for the same log. + +## Decision + +The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. + +`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the expanded body, its 141px scrollport, and the truncation policy are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). + +**The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. + +`recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. + +`MessageItem` captions durable and pending steering bubbles with `插话`. This reverses one clause of [no steer entry or interjection chrome](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. + +## Alternatives considered + +**Localize producer names in the client.** A dictionary keyed by plugin id would read better than `@deepseek-ai/dsh-system-prompt`, but it drifts silently on every rename, needs a client change per new producer, and cannot name a producer from a foreign log at all. Provenance the log already carries is worth more than prose the client invents. + +**Register presentations per source kind.** The disclosure decision deferred a keyed context-view slot until source-owned presentations emerged. Naming a row is not a distinct presentation, and a registry keyed on mounted producers would fail exactly where it matters — a resumed log whose producer is no longer mounted still has to render. + +**Compute the role and label on the host.** The host would have to attach a view to each event copy, duplicating what the durable source already states and adding a wire field per context message. The projection derives it once per node instead, where the transcript's other derived facts live. + +**Give steering its own row instead of a captioned bubble.** Steering is a user message that arrived mid-turn; a separate row shape would break the right-aligned reading rhythm and duplicate the bubble's copy and branch actions for no new information. + +**Extend the trajectory table with the same names.** Out of scope: the table's context cell has its own text derivation, and the issue asks for the conversation surface. + +## Testing + +- `packages/client/runtime` unit coverage pins each provenance arm, the label fallbacks when a name field is missing, empty, or wrongly typed, and the unnamed degradation for a source with no readable kind. +- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles. +- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks. + +## Consequences + +- A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. +- Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better name records better provenance. +- `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. +- The `recall` arm has no producer in a shipped Web leaf until a host mounts `dsh-session-reference`; it is reachable only through logs written elsewhere. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md new file mode 100644 index 0000000000..46f5285033 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -0,0 +1,48 @@ +# Agent Note:Web transcript 标出上下文来源、召回与 steering + +Status: implemented + +[English](2026-08-04-web-context-source-and-steer-marks.md) | 中文 + +## Problem + +生产方向模型侧对话补充的一切内容,进入 Web transcript(文本记录)后只剩两种匿名形态。每一条已记录的非用户 `user/message`——skill 目录、运行时快照、经过对账的 `AGENTS.md` 指令、guard 提示、子 agent 汇报、跨会话快照——都塌缩成同一行 `上下文注入`,读者不逐行展开去读原始 JSON 就无从知道究竟注入了什么。steering(中途引导)的情况更糟:它渲染成与开轮提示完全相同的气泡,于是 transcript 无法说明哪一条消息打断了正在运行的轮次。 + +这些区分本来就是持久事实。`user/message.source` 是每个生产方都必须提供的可合并扩展来源,steering 另有自己的 `steering/message` 事件;把它们丢掉的只有呈现层。被这套 Web UI 取代的终端 transcript 本来会写出每张卡片的生产者,因此面对同一份日志,Web 侧是一次倒退。 + +## Decision + +transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 + +`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;展开内容区、141px 滚动视口与截断策略沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。 + +**名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 + +`recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 + +`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。这推翻了[取消 steer 入口与插话装饰](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论——当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 + +## Alternatives considered + +**在客户端本地化生产者名称。** 以插件 id 为键的字典读起来确实比 `@deepseek-ai/dsh-system-prompt` 好,但它会在每次重命名时悄悄失准,每新增一个生产者都要改客户端,而且对来自外部的日志根本无法命名。日志已经承载的来源,比客户端自己编出来的措辞更有价值。 + +**按来源 kind 注册呈现。** 展开项决策把键控的 context-view 槽位推迟到出现由来源自有的呈现需求为止。为一行命名并不构成独立呈现,而以「已挂载的生产者」为键的注册表恰恰会在最要紧的地方失效——生产者已不再挂载的恢复日志同样必须渲染出来。 + +**在 host 侧计算角色与名称。** 那需要为每份事件副本附加一个视图,重复陈述持久来源已经说明的事实,并为每条上下文消息增加一个 wire 字段。改由投影为每个节点计算一次,与 transcript 其他派生事实同处一地。 + +**给 steering 独立的行而非带标注的气泡。** steering 是一条在轮次中途抵达的用户消息;独立行形会打断右对齐的阅读节奏,并且要为零新增信息重复气泡上的复制与分支操作。 + +**把同一套名称扩展到 trajectory 表格。** 不在本次范围内:该表格的上下文单元格有自己的文本推导,而 issue 要求的是对话面。 + +## Testing + +- `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退,以及来源没有可读 kind 时的无名降级。 +- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。 +- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 + +## Consequences + +- 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 +- 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好名称的生产者应当记录更好的来源。 +- `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 +- 在某个宿主挂载 `dsh-session-reference` 之前,`recall` 分支在已发布的 Web 叶子中没有生产者,只能通过别处写入的日志抵达。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml index 2059841653..2518f0de2d 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 6f397668106a6c74f327fc799327752c54824d8c -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: f127bd57006747465a1ece87406f5f97086b6a34 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 15b9b9d299161e9d7404b43cfabfdc11e87d8feb +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 8c25f2700c316e75d29143e85c4c966fcb744c85 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index 6f39766810..15b9b9d299 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -28,6 +28,7 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: ## Consequences +- **Superseded in part.** Composer steering shipped afterwards, so the first Decision bullet and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again and Web users can steer. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold. - Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. - Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. - Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index f127bd5700..8c25f2700c 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -28,6 +28,7 @@ Status: implemented ## 后果 +- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue`/`steer` mode 联合类型,Web 用户可以 steer。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。 - Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 - Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 - 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index ba0cb50e40..718aabbbce 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -236,7 +236,10 @@ describe('web e2e: seeded history renders through cold resume', () => { }], }, })) - await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 }) + // The header names the producer the durable source records, so the + // reconciled instruction file is readable without expanding the row. + await page.getByRole('button', { name: 'Context injection · AGENTS.md', exact: true }) + .waitFor({ timeout: 10_000 }) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -254,7 +257,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) - const disclosure = page.getByRole('button', { name: 'Context injection' }) + const disclosure = page.getByRole('button', { name: 'Context injection · AGENTS.md', exact: true }) expect(await disclosure.getAttribute('aria-expanded')).toBe('false') const collapsedIcon = disclosure.locator('svg').first() const collapsedIconBox = await collapsedIcon.boundingBox() @@ -365,9 +368,8 @@ describe('web e2e: seeded history renders through cold resume', () => { source: { kind: 'plugin', plugin: 'fixture' }, })) - const disclosures = page.getByRole('button', { name: 'Context injection' }) - await expect.poll(() => disclosures.count(), { timeout: 10_000 }).toBe(2) - const disclosure = disclosures.nth(1) + const disclosure = page.getByRole('button', { name: 'Context injection · fixture', exact: true }) + await disclosure.waitFor({ timeout: 10_000 }) await disclosure.click() await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 8f09d36efd..f738eb0ff5 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - 'button "Failed Bash Error: command aborted" [expanded]': - img - text: "Failed Bash Error: command aborted" diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 42758237bb..cde7820e28 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 85ef8932b2..c3e97df0f9 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to:": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 159e77d5bc..959e9db772 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index f6f965337e..2d1aa1854f 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to reply with a single word. Let me comply.": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index cd06310db0..3b67a54c51 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1a4aec678c..508958f617 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - status: - text: This turn failedAPI key is invalid - code: AUTH diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index c50b440f86..9ad29a3bcf 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 5f834e15f0..eba44b97c1 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - group: - status: Retried model request (1/2) · {{duration}} - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 2120c32408..18fbf294b5 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -11,10 +11,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."': - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index a54b7dcd75..3cfde08bd2 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index bd44e33ad0..7bbe5f5cb5 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 8df2ea2940..d31126510c 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" [disabled] [expanded] diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 08aea1a327..665eb1ccca 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -8,18 +8,18 @@ - img - img - text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear" -- button "Context injection": +- button "Context injection · goal": - img - img - - text: Context injection -- button "Context injection": + - text: Context injection · goal +- button "Context injection · goal": - img - img - - text: Context injection -- button "Context injection": + - text: Context injection · goal +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - region "To-dos": diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 2ce0077a2e..866047f95d 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 7afdf3c8b4..6476183b5f 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index d638821c45..2f0d9334f8 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -37,10 +37,10 @@ - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection": +- button "Context injection · AGENTS.md": - img - img - - text: Context injection + - text: Context injection · AGENTS.md - img - text: permission preset read-only - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 9e78cca0f8..565150c4f9 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -37,10 +37,10 @@ - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection": +- button "Context injection · AGENTS.md": - img - img - - text: Context injection + - text: Context injection · AGENTS.md - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5b316f4efb..93ceadfa28 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -24,7 +24,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Interjection: include the word BANANA in your final reply." +- text: "Steer Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 00f1b33206..2066d12848 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -22,7 +22,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Steer Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Branch into a new conversation" [disabled]: diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 561277b8ee..8480bf9bbe 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -15,10 +15,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 45098769f7..567e3f83c3 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection": +- button "Context injection · @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection + - text: Context injection · @deepseek-ai/dsh-system-prompt - button "Search DeepSeek Harness snapshot search": - img - img diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index c3850804ff..44a1c9d0c4 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 -README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a +README.md: 80fb7e02ff357694bef5237a86983a22bf222572 +README.zh.md: b242720fc1ed693a1f681c186adf5ef4f1cef42a diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 89e58f967f..80fb7e02ff 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 960e2fceed..b242720fc1 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 0a384fffa2..ac1e84793b 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -53,6 +53,7 @@ export type { export type { ConversationContext, ConversationContextOriginKind, } from './sessions/conversation-context.ts' +export type { ContextProvenanceView, ContextRole } from './sessions/context-provenance.ts' export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, } from './sessions/request-inspection.ts' diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index c4bc6ed9b5..af915b1deb 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -11,6 +11,7 @@ import type { PartialAssistant, RunningToolCall, } from '../sessions/conversation.ts' import { toAssistantBlocks } from '../sessions/conversation.ts' +import { contextProvenance } from '../sessions/context-provenance.ts' import type { ConversationContext, ConversationContextOriginKind, } from '../sessions/conversation-context.ts' @@ -126,6 +127,7 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + provenance: contextProvenance(event.data.source), } } return { diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts new file mode 100644 index 0000000000..3237c1a22e --- /dev/null +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -0,0 +1,91 @@ +// Context provenance projection: the role and the human-facing producer name +// of one logged non-user `user/message`, read from its durable `source` alone. +// The client keeps no table of known plugin ids — a renamed or newly mounted +// producer must never need a client release to stay identifiable, and a resumed +// or foreign log must project the same way as a live one. + +/** + * Which model-facing role a logged non-user message plays. + * + * `recall` marks material lifted out of another session's log; `inject` marks + * every other producer-supplied context. Mid-turn steering is the third role + * the transcript distinguishes, but it has its own event and node kind + * (`steering/message` / `SteeringMessageNode`) and never reaches here. + */ +export type ContextRole = 'inject' | 'recall' + +/** Role and producer name presented for one logged non-user message. */ +export interface ContextProvenanceView { + /** The role this context plays in the model-facing conversation. */ + role: ContextRole + /** + * Producer name for the row header, taken from the durable source: the + * instruction paths, the referenced session titles, the plugin id, or the + * bare source kind for a producer this UI version does not know. Null only + * when the source carries no readable kind at all. + */ + label: string | null +} + +/** One durable source narrowed to the readable-record shape; null for anything else. */ +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +/** A record field read as a non-empty string, or null. */ +function readString(record: Record, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */ +function collect(source: Record, member: string, field: string): string[] { + const list = source[member] + if (!Array.isArray(list)) return [] + const seen: string[] = [] + for (const entry of list) { + const record = asRecord(entry) + const value = record === null ? null : readString(record, field) + if (value !== null && !seen.includes(value)) seen.push(value) + } + return seen +} + +/** A collected name list rendered as one label; null when the list is empty. */ +function joined(names: string[]): string | null { + return names.length > 0 ? names.join(', ') : null +} + +/** + * Project one durable message source onto its transcript role and producer name. + * + * The source arrives over the wire as opaque JSON (`MessageSource` is + * merge-extensible, so no client-side union can be exhaustive), and a durable + * log may predate or postdate this UI; every unreadable shape therefore + * degrades to `inject` with whatever name the record still carries. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns the role and producer name to present for this context. + */ +export function contextProvenance(source: unknown): ContextProvenanceView { + const record = asRecord(source) + const kind = record === null ? null : readString(record, 'kind') + if (record === null || kind === null) return { role: 'inject', label: null } + switch (kind) { + // Cross-session snapshots are the one durable source that carries another + // session's material; its references name the sessions they were read from. + case 'session-reference': + return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind } + // Workspace instructions name the files they were reconciled from, which + // identifies the producer far better than the plugin id would. + case 'workspace-instructions': + return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } + case 'plugin': + return { role: 'inject', label: readString(record, 'plugin') ?? kind } + // Documented default arm of the merge-extensible source map: an unknown + // producer still identifies itself by its own durable kind. + default: + return { role: 'inject', label: kind } + } +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index c4e195bc88..b4c7ebb71b 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -12,6 +12,7 @@ import type { InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' +import type { ContextProvenanceView } from './context-provenance.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -123,6 +124,8 @@ export interface ContextMessageNode { time: number content: readonly ContentBlock[] source: unknown + /** Role and producer name projected from `source` ({@link contextProvenance}). */ + provenance: ContextProvenanceView } /** Durable notice that a closed failed step is waiting for a model-request retry. */ diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 05ac3cb067..854c461755 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -22,6 +22,7 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' +import { contextProvenance } from './context-provenance.ts' /** * The compaction seam's checkpoint plugin, pinned to the seam's own declaration @@ -60,6 +61,7 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + provenance: contextProvenance(event.data.source), } } return { diff --git a/packages/client/runtime/tests/context-provenance.spec.ts b/packages/client/runtime/tests/context-provenance.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..94d1a12038340e835db2da08095a63ac2e9bc250 GIT binary patch literal 3196 zcmcguO>f&q5basNVs4ZTNkoDG1q7r(8@PopL6EekAcqyX61Q6JvO7!5f}sE2H~bJK zTXy86so(=m?tHy@^JdfQ>k(bkJMW^tk5mPJr=4<{mdC$!7L9jviiM`&ttRDi`HvJE$@!Gpm)JP|zx;W$(f!Ypz!8uekv$s?D^gvM9&_7~cX2+52S0fm7|W$Tr316YeK zK64L7_toSl_SBU%eiUZBqqTAQoRZcOX6*ysx>Xy7~{fOpkL(Cf%upVr`8>t;kdNZ?i(VmF78p z?v4@2q&jjZtOXIyd?gUge){!~oA-Zynw8R=O2q;%hgY=koj&7(>%}=Qc%6RLQ_z(K z)ZdcPmo_yJ;DXK{5<%$?@)G_G94HmK4@PIfGoyjcun;>{Va_8dmbNQv8*ymqFSO{V zN=8&B{Tut;xtRvN)Imd-4z0e^LB@7YulkN0&Pbn1j?ecuxIUn%BQy34IEivZ2m%ZdFWSI zbxf@#XagNl*_xtny3wh>R6&~pS|KfJ#a?!ZY#cyjp(sqXt65gYgL2c9ZUEPoR48e! zb=8PdQ>u`)QDMa`DVvQWUgscqX+Uka!g49AxSc?z8nuAGP4I4Ix6`<-PO~U$IFfcO zta2y<$zlgm0lw>i?S)^P`~{K@aApldWqNc!D_&t4}Fw~5O#>JxhNTBvi*7-YcTzsmm`G5`Po literal 0 HcmV?d00001 diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index cc03d349b8..9aa2654554 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -208,7 +208,9 @@ describe('TranscriptAdapter', () => { source: { kind: 'plugin', plugin: 'compact' }, }) }), ]) - expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }]) + expect(adapter.nodes()).toMatchObject([{ + kind: 'context', seq: 0, provenance: { role: 'inject', label: 'compact' }, + }]) }) it('ignores a foreign plugin s replacement user/message', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..3c9b38b984 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: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: c7ac25a47eceb2f06ae90bf540a37b77c67a0d8a +README.zh.md: 1b1d250b8623aa164119e85caa2631805b2879a4 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..c7ac25a47e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -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)). +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls 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 ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). A durable or pending steering bubble carries an `插话` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. 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)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..1b1d250b86 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill 目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。持久与待处理的 steering(中途引导)气泡上方带有 `插话` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css index e603931a27..a3e5458096 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -12,6 +12,20 @@ color: var(--dsw-alias-label-secondary); } +/* Producer name beside the role title: ToolRow's summary geometry, so the two + disclosure rows keep one 24px rhythm; subordinate color, truncated when long. */ +.source { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + margin-left: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + .body { box-sizing: border-box; width: calc(100% - 22px); diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 4f7bbff348..e5f9ee8567 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -48,16 +48,22 @@ function inlineJson(payload: unknown): string { export interface ContextInjectionRowProps { content: ContextMessageNode['content'] source: ContextMessageNode['source'] + /** Role and producer name projected from the durable source. */ + provenance: ContextMessageNode['provenance'] /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } /** * Render logged context with the Tool calls disclosure chrome from Figma. - * @param props - Durable content and source provenance. + * + * The header names the role the context plays and, beside it, the producer the + * durable source identifies, so a reader can tell an injected skill catalog + * from a workspace instruction file or a recalled session without expanding. + * @param props - Durable content, its projected provenance, and the locale seat. * @returns A collapsed context row with a bounded JSON body. */ -export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) { +export function ContextInjectionRow({ content, source, provenance, t }: ContextInjectionRowProps) { const [open, setOpen] = useState(false) const body = useMemo(() => { if (!open) return '' @@ -72,7 +78,11 @@ export function ContextInjectionRow({ content, source, t }: ContextInjectionRowP className={css.root} icon={} chevronClassName={css.chevron} - title={t('message.contextInjection')} + title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} + collapsedContent={provenance.label === null ? undefined : ( + {`· ${provenance.label}`} + )} + keepContentWhenOpen open={open} expandable expandOnRowClick diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 323c5a5769..5c07ace71e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -8,6 +8,15 @@ gap: 6px; } +/* Steering caption above the bubble: mid-turn interjections carry the same + bubble as a turn-opening prompt, so the transcript names which one this is. */ +.steeringMark { + padding-right: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 16px; +} + .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ max-width: min(525px, 82%); diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a0bed9a38e..73dbcdb509 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -171,19 +171,22 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, actions, pending = false, t, + content, actions, pending = false, steering = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */ + steering?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return (
+ {steering && {t('message.steering')}}
{projectUserText(text)} {rest.map((block, i) => )} @@ -207,6 +210,7 @@ export function PendingSteeringBubble({ content, t }: { ( ( + ) case 'compaction': return diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 7a38aaa0f8..fe20201387 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -52,6 +52,8 @@ export const zh = { 'chat.toBottom': '回到底部', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', + 'message.contextRecall': '跨会话召回', + 'message.steering': '插话', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -163,6 +165,8 @@ export const en = { 'chat.toBottom': 'Back to bottom', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', + 'message.contextRecall': 'Session recall', + 'message.steering': 'Steer', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 89a09ca28d..c74defc4f7 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -130,7 +130,7 @@ describe('MessageItem arms', () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) - it('consumed steering renders copy and branch actions without a badge', () => { + it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -145,7 +145,7 @@ describe('MessageItem arms', () => { onFork={fork} />, ) - expect(view.queryByText('插话')).toBeNull() + expect(view.getByText('插话')).toBeTruthy() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) @@ -161,10 +161,11 @@ describe('MessageItem arms', () => { seq: 3, content: [{ type: 'text', text: 'x\n"y":,[{}]' }], source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] }, + provenance: { role: 'inject', label: 'fixture' }, } as never} />, ) - const disclosure = ctxView.getByRole('button', { name: '上下文注入' }) + const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*· fixture$/ }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() expect(ctxView.container.querySelector('svg')).not.toBeNull() @@ -187,6 +188,7 @@ describe('MessageItem arms', () => { seq: 3, content: [{ type: 'text', text: 'x'.repeat(21_000) }], source: null, + provenance: { role: 'inject', label: null }, } as never} />, ) @@ -195,6 +197,50 @@ describe('MessageItem arms', () => { .toMatch(/… 已截断,共 \d+ 字符$/) }) + it('a recalled session titles its row by role and names the sessions it read', () => { + const view = render( + , + ) + expect(view.getByRole('button', { name: /^跨会话召回\s*· 重构 loader$/ })).toBeTruthy() + expect(view.queryByText('上下文注入')).toBeNull() + }) + + it('keeps the producer name visible while the context body is expanded', () => { + const view = render( + , + ) + const disclosure = view.getByRole('button', { name: /^上下文注入\s*· AGENTS\.md$/ }) + fireEvent.click(disclosure) + expect(disclosure.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('· AGENTS.md') + }) + + it('a context source that names no producer shows the role alone', () => { + const view = render( + , + ) + expect(view.getByRole('button', { name: '上下文注入' })).toBeTruthy() + expect(view.container.querySelector('[data-context-source]')).toBeNull() + }) + it('unknown nodes retain the generic JSON row', () => { const unknownView = render( , diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 3a0ca4f7b0..3301bf5af6 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -380,6 +380,9 @@ describe('ChatView', () => { expect(view.queryByText('later')).toBeNull() const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') expect(pendingBubble).not.toBeNull() + // Pending and durable steering carry the same interjection caption, so the + // hand-off does not change what the row says it is. + expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy() fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() @@ -401,6 +404,7 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() + expect(view.getAllByText('插话')).toHaveLength(1) expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' }) @@ -447,6 +451,7 @@ describe('ChatView', () => { const nextRetry = { ...retry(3), turn: 2, retry: 2 } const context = { kind: 'context', seq: 4, time: 4_000, content: [], source: null, + provenance: { role: 'inject', label: null }, } as const satisfies ConversationNode const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) const view = render() diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index ae1fecbaea..0e63a24a48 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -13,6 +13,7 @@ const checkpoint = { time: 100, content: [], source: { kind: 'plugin', plugin: 'compact' }, + provenance: { role: 'inject', label: 'compact' }, } as ConversationNode const abandoned = { From 95a89078d53db942bac85f2dce16f04fbbbb19a4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 17:53:26 +0800 Subject: [PATCH 010/179] docs(web): refresh the MessageItem module comment for the steering caption --- .../client/ui-conversation/src/client/chat/MessageItem.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 73dbcdb509..b15bd2f987 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,7 +1,8 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (right-aligned, with clock + copy / branch IconActions), pending steering -// (copy only), context injection, compaction marker, retry disclosure, and -// unknown-surface JSON rows. +// (right-aligned, with clock + copy / branch IconActions; steering adds the +// interjection caption that names it), pending steering (caption + copy only), +// context injection, compaction marker, retry disclosure, and unknown-surface +// JSON rows. import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' From 8e4a1b3396c1023e5b28f22c09c2191027883108 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 18:00:09 +0800 Subject: [PATCH 011/179] fix(subagent): keep Codex provider opt-in --- ...-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...6-08-04-claude-code-and-codex-subagent-backends.md | 4 +--- ...8-04-claude-code-and-codex-subagent-backends.zh.md | 4 +--- apps/cli/composition.md | 6 ------ apps/cli/config/base.cordis.yml | 11 ----------- apps/cli/package.json | 1 - apps/cli/tests/built-bin.e2e.ts | 6 ------ pnpm-lock.yaml | 3 --- 8 files changed, 4 insertions(+), 35 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 91abd43031..8431b6bbce 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 52181edba816f651877eac2c616cf67191f7e550 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: bde24eb8939b10c10916dab93effd3b94cbc7e1b +2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 52181edba8..37f45f844c 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -16,7 +16,7 @@ The harness provides two sibling one-shot providers behind two fixed model-facin The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their fixed tools use `maxDepth: 'provider-managed'` because each out-of-process product owns any delegation budget inside its own harness; the parent sends no recursion cap that the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text fixed tool → shared subagent service → product provider → official product process @@ -37,8 +37,6 @@ fixed tool → shared subagent service → product provider → official product `@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. -The shipped `apps/cli/config/base.cordis.yml` loads this provider and a fixed `subagent_codex` tool by default, while `apps/cli/package.json` carries the provider package in the CLI dependency closure. Loading the base does not probe the Codex binary or authentication; missing native availability fails only when the tool is called. - Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. `turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A completed turn without an answer, a failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`. Local cancellation wins its race and remains `aborted`. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index bde24eb893..dcd7b90231 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -16,7 +16,7 @@ harness 在两个固定的面向模型工具背后提供两个一次性兄弟提 Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。固定工具使用 `maxDepth: 'provider-managed'`,因为每个进程外产品都负责其自身 harness 内部的委派预算;父级不会传入提供方无法执行的递归上限。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text fixed tool → shared subagent service → product provider → official product process @@ -37,8 +37,6 @@ fixed tool → shared subagent service → product provider → official product `@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 -正式发布的 `apps/cli/config/base.cordis.yml` 默认加载这个提供方和固定的 `subagent_codex` 工具,而 `apps/cli/package.json` 将提供方包纳入 CLI 依赖闭包。加载基础配置时不会探测 Codex 二进制程序或身份验证;缺少原生可用条件只会在工具实际调用时失败。 - 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 `turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。轮次完成却没有答案、远端轮次失败或中断、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`。本地取消在竞态中胜出并保持为 `aborted`。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index a24fbd20f6..0bede25716 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,8 +94,6 @@ flowchart LR cfg --> plugin_dsh_base_subagent_spawn plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_dsh_base_subagent_fork - plugin_dsh_base_subagent_codex["subagent-codex
@deepseek-ai/dsh-subagent-codex"] - cfg --> plugin_dsh_base_subagent_codex plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] @@ -104,8 +102,6 @@ flowchart LR cfg --> plugin_dsh_base_tool_subagent plugin_dsh_base_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_dsh_base_tool_subagent_fork - plugin_dsh_base_tool_subagent_codex["tool-subagent-codex
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_dsh_base_tool_subagent_codex plugin_dsh_base_tool_subagent_report["tool-subagent-report
@deepseek-ai/dsh-tool-subagent-report"] cfg --> plugin_dsh_base_tool_subagent_report plugin_dsh_base_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] @@ -195,12 +191,10 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `tool-subagent-codex` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 5bc053c8c8..623b4d1153 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -254,9 +254,6 @@ config: providerName: fork -- id: subagent-codex - name: '@deepseek-ai/dsh-subagent-codex' - # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control @@ -279,14 +276,6 @@ toolName: subagent_fork backgroundMode: continuable -- id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: codex - toolName: subagent_codex - enableRunInBackground: false - maxDepth: 'provider-managed' - # Optional direct-child return channel; absent from roots and one-shot agents. - id: tool-subagent-report name: '@deepseek-ai/dsh-tool-subagent-report' diff --git a/apps/cli/package.json b/apps/cli/package.json index b69fb30a73..0ccf4b2197 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -98,7 +98,6 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-codex": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 5e3d6442d2..fcfe8b3829 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -85,7 +85,6 @@ function startRawLifecycle(fixture: RawLifecycleFixture) { env: { DSH_HOME: fixture.home, DSH_TELEMETRY_DISABLED: '1', - PATH: fixture.home, RAW_READY_FILE: fixture.ready, RAW_SETTLED_FILE: fixture.settled, RAW_DISPOSED_FILE: fixture.disposed, @@ -164,11 +163,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") expect(stdout).toContain('agents: []') expect(stdout).toContain('# == base.cordis.yml') - expect(stdout).toContain("name: '@deepseek-ai/dsh-subagent-codex'") - expect(stdout).toContain('provider: codex') - expect(stdout).toContain('toolName: subagent_codex') - expect(stdout).toContain('enableRunInBackground: false') - expect(stdout).toContain('maxDepth: provider-managed') }, 30_000) it('composes the required raw overlay directly over the base', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d85ed96bf7..237b8296c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,9 +377,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent - '@deepseek-ai/dsh-subagent-codex': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork From fab131a3666c86d565575c5d205ed248199d7c3a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 18:25:44 +0800 Subject: [PATCH 012/179] fix(web): align the context separator with ToolRow and name the caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the context provenance row: - the producer name now follows ToolRow's aria-hidden separator dot instead of a literal middot inside the text, so the two disclosure rows expose one accessible-name shape; - the English steering caption becomes the noun `Interjection`, parallel to the Chinese 插话 and to the label this bubble carried before; - the history fold gets its own provenance assertion, so the mapping it declares separately from TranscriptAdapter is pinned on both sides; - the superseded-in-part bullet on the no-interjection-chrome note now enumerates the third Decision bullet too, whose no-badge and no-IconActions clauses had both lapsed. --- ...eer-entry-or-interjection-chrome.i18n.yaml | 4 ++-- ...i-no-steer-entry-or-interjection-chrome.md | 2 +- ...o-steer-entry-or-interjection-chrome.zh.md | 2 +- apps/web/tests/seeded-history.e2e.ts | 6 ++--- .../snapshots/bash-abort-row/ui.expected.md | 4 ++-- .../snapshots/code-mode-round/ui.expected.md | 4 ++-- .../cordis-tool-round/ui.expected.md | 4 ++-- .../snapshots/fresh-round-trip/ui.expected.md | 4 ++-- .../lifecycle-chrome/reloaded.expected.md | 4 ++-- .../live-interactions/cancel.expected.md | 4 ++-- .../live-interactions/error-auth.expected.md | 4 ++-- .../live-interactions/loading.expected.md | 4 ++-- .../live-interactions/retry.expected.md | 4 ++-- .../plan-review/approved.expected.md | 4 ++-- .../question-composer/answered.expected.md | 4 ++-- .../queue-actions/collapsed.expected.md | 4 ++-- .../queue-actions/editing.expected.md | 4 ++-- .../queue-actions/layout.expected.md | 12 +++++----- .../queue-actions/preserved.expected.md | 4 ++-- .../snapshots/queue-actions/ui.expected.md | 4 ++-- .../seeded-history/command-row.expected.md | 4 ++-- .../snapshots/seeded-history/ui.expected.md | 4 ++-- .../snapshots/steering/mid-steer.expected.md | 6 ++--- .../snapshots/steering/settled.expected.md | 6 ++--- .../subagent-conversation/ui.expected.md | 4 ++-- .../snapshots/web-search-round/ui.expected.md | 4 ++-- .../client/runtime/tests/history-fold.spec.ts | 22 ++++++++++++++++++- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../chat/ContextInjectionRow.module.css | 14 +++++++++--- .../src/client/chat/ContextInjectionRow.tsx | 8 ++++++- .../ui-conversation/src/client/locales.ts | 2 +- .../tests/chat-branch-tails.spec.tsx | 8 +++---- 34 files changed, 105 insertions(+), 71 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml index 2518f0de2d..b5cdee7d4b 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 15b9b9d299161e9d7404b43cfabfdc11e87d8feb -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 8c25f2700c316e75d29143e85c4c966fcb744c85 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 9dc87cd69f5d274ba5e8d66c30805cada7f1fa1a +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: e650b660efe0bf308a4b1a8c72e9c89a133ac769 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index 15b9b9d299..9dc87cd69f 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -28,7 +28,7 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: ## Consequences -- **Superseded in part.** Composer steering shipped afterwards, so the first Decision bullet and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again and Web users can steer. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold. +- **Superseded in part.** Composer steering shipped afterwards, so the first and third Decision bullets and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again, Web users can steer, and the steering bubble regained its user IconActions before carrying a caption again. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold. - Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. - Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. - Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index 8c25f2700c..e650b660ef 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 后果 -- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue`/`steer` mode 联合类型,Web 用户可以 steer。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。 +- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与第三条、以及下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue`/`steer` mode 联合类型,Web 用户可以 steer,steering 气泡也早在重新带上标注之前就恢复了用户 IconActions。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。 - Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 - Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 - 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 718aabbbce..784589d9c7 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -238,7 +238,7 @@ describe('web e2e: seeded history renders through cold resume', () => { })) // The header names the producer the durable source records, so the // reconciled instruction file is readable without expanding the row. - await page.getByRole('button', { name: 'Context injection · AGENTS.md', exact: true }) + await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) .waitFor({ timeout: 10_000 }) }, 60_000) @@ -257,7 +257,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) - const disclosure = page.getByRole('button', { name: 'Context injection · AGENTS.md', exact: true }) + const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) expect(await disclosure.getAttribute('aria-expanded')).toBe('false') const collapsedIcon = disclosure.locator('svg').first() const collapsedIconBox = await collapsedIcon.boundingBox() @@ -368,7 +368,7 @@ describe('web e2e: seeded history renders through cold resume', () => { source: { kind: 'plugin', plugin: 'fixture' }, })) - const disclosure = page.getByRole('button', { name: 'Context injection · fixture', exact: true }) + const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true }) await disclosure.waitFor({ timeout: 10_000 }) await disclosure.click() await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index f738eb0ff5..a8f9b82caa 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - 'button "Failed Bash Error: command aborted" [expanded]': - img - text: "Failed Bash Error: command aborted" diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index cde7820e28..3bd030ce88 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index c3e97df0f9..57261af55d 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to:": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 959e9db772..b259813833 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 2d1aa1854f..1b6e48466a 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to reply with a single word. Let me comply.": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 3b67a54c51..f1c57be871 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 508958f617..be1d936dd2 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - status: - text: This turn failedAPI key is invalid - code: AUTH diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index 9ad29a3bcf..6e81c87205 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index eba44b97c1..bcfd99e77b 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - group: - status: Retried model request (1/2) · {{duration}} - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 18fbf294b5..71876c40ae 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -11,10 +11,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."': - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 3cfde08bd2..05c3448ad7 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index 7bbe5f5cb5..cdde5d8790 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index d31126510c..1b08108eca 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - button "2 queued messages" [disabled] [expanded] diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 665eb1ccca..4b96025e07 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -8,18 +8,18 @@ - img - img - text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear" -- button "Context injection · goal": +- button "Context injection goal": - img - img - - text: Context injection · goal -- button "Context injection · goal": + - text: Context injection goal +- button "Context injection goal": - img - img - - text: Context injection · goal -- button "Context injection · @deepseek-ai/dsh-system-prompt": + - text: Context injection goal +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - region "To-dos": diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 866047f95d..f1db583938 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 6476183b5f..2276449812 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 2f0d9334f8..0168ecdfbe 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -37,10 +37,10 @@ - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection · AGENTS.md": +- button "Context injection AGENTS.md": - img - img - - text: Context injection · AGENTS.md + - text: Context injection AGENTS.md - img - text: permission preset read-only - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 565150c4f9..482cd7a3e3 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -37,10 +37,10 @@ - button "Context compacted View compaction summary": - img - text: Context compacted View compaction summary -- button "Context injection · AGENTS.md": +- button "Context injection AGENTS.md": - img - img - - text: Context injection · AGENTS.md + - text: Context injection AGENTS.md - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 93ceadfa28..c32cee0077 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -24,7 +24,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Steer Interjection: include the word BANANA in your final reply." +- text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 2066d12848..c20cab773a 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -22,7 +22,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Steer Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Branch into a new conversation" [disabled]: diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 8480bf9bbe..401061a3b6 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -15,10 +15,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 567e3f83c3..2b355156e8 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -10,10 +10,10 @@ - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn -- button "Context injection · @deepseek-ai/dsh-system-prompt": +- button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - - text: Context injection · @deepseek-ai/dsh-system-prompt + - text: Context injection @deepseek-ai/dsh-system-prompt - button "Search DeepSeek Harness snapshot search": - img - img diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 0c5421e491..527e324e26 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -1,4 +1,4 @@ -import { createMessage } from '@deepseek-ai/dsh-llm' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { describe, expect, it } from 'vitest' import { projectConversationHistory } from '../src/client/session-history/history-fold.ts' @@ -8,6 +8,26 @@ const at = (seq: number, event: Record): SessionEvent => ({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent describe('projectConversationHistory', () => { + it('names an injected context node from its durable source, like the live adapter', () => { + // The fold declares its own node mapping (jscpd:ignore in the source), so + // the provenance projection is pinned on both sides independently. + const injected = at(0, { + type: 'user/message', + surfaceOp: 'append', + data: createUserMessage({ + content: [{ type: 'text', text: '' }], + // A plugin source, because the client program does not see the host + // packages that merge richer source kinds; those arms are pinned in + // context-provenance.spec.ts. + source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + }), + }) + const { contexts } = projectConversationHistory([{ event: injected }]) + expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{ + kind: 'context', seq: 0, provenance: { role: 'inject', label: 'dsh-tool-skill' }, + }]) + }) + it('projects frozen surface generations without widening the core live surface', () => { const events = [ ev.user(0, 'a'), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3c9b38b984..9028fca98c 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: c7ac25a47eceb2f06ae90bf540a37b77c67a0d8a -README.zh.md: 1b1d250b8623aa164119e85caa2631805b2879a4 +README.md: 9d715b08ea86dd0825deb51b01037949605a93d4 +README.zh.md: 29c7f9877d1161e8b8f560a2cd61e0e7c9e4cc34 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c7ac25a47e..9d715b08ea 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls 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 ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). A durable or pending steering bubble carries an `插话` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls 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 ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. 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)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1b1d250b86..29c7f9877d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill 目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。持久与待处理的 steering(中途引导)气泡上方带有 `插话` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill 目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。持久与待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css index a3e5458096..a15d66efc3 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -12,13 +12,21 @@ color: var(--dsw-alias-label-secondary); } -/* Producer name beside the role title: ToolRow's summary geometry, so the two - disclosure rows keep one 24px rhythm; subordinate color, truncated when long. */ +/* Separator and producer name beside the role title: ToolRow's summary geometry, + so the two disclosure rows keep one 24px rhythm and one separator shape. */ +.sep { + flex: none; + width: 2px; + height: 2px; + margin: 0 8px; + border-radius: 1px; + background: var(--dsw-alias-label-caption); +} + .source { flex: 1 1 auto; min-width: 0; overflow: hidden; - margin-left: 4px; color: var(--dsw-alias-label-tertiary); font-size: 14px; line-height: 24px; diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index e5f9ee8567..31b2092e6e 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -80,7 +80,13 @@ export function ContextInjectionRow({ content, source, provenance, t }: ContextI chevronClassName={css.chevron} title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} collapsedContent={provenance.label === null ? undefined : ( - {`· ${provenance.label}`} + /* ToolRow's separator shape: an aria-hidden dot, so the accessible name + stays the two readable parts and the two disclosure rows expose one + name shape. A source that names no producer drops the dot with it. */ + <> + + {provenance.label} + )} keepContentWhenOpen open={open} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index fe20201387..005e5cad24 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -166,7 +166,7 @@ export const en = { 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', - 'message.steering': 'Steer', + 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index c74defc4f7..a784d1555e 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -165,7 +165,7 @@ describe('MessageItem arms', () => { } as never} />, ) - const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*· fixture$/ }) + const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() expect(ctxView.container.querySelector('svg')).not.toBeNull() @@ -208,7 +208,7 @@ describe('MessageItem arms', () => { } as never} />, ) - expect(view.getByRole('button', { name: /^跨会话召回\s*· 重构 loader$/ })).toBeTruthy() + expect(view.getByRole('button', { name: /^跨会话召回\s*重构 loader$/ })).toBeTruthy() expect(view.queryByText('上下文注入')).toBeNull() }) @@ -223,10 +223,10 @@ describe('MessageItem arms', () => { } as never} />, ) - const disclosure = view.getByRole('button', { name: /^上下文注入\s*· AGENTS\.md$/ }) + const disclosure = view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md$/ }) fireEvent.click(disclosure) expect(disclosure.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('· AGENTS.md') + expect(view.container.querySelector('[data-context-source]')?.textContent).toBe('AGENTS.md') }) it('a context source that names no producer shows the role alone', () => { From c09b20f96b08f5119712c0017858dde214e61236 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 18:38:05 +0800 Subject: [PATCH 013/179] fix(subagent): finalize Codex provider composition --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 20 +- ...ude-code-and-codex-subagent-backends.zh.md | 20 +- docs/capability-seams.md | 7 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../subagent/subagent-codex/cordis.yml | 19 +- .../subagent/subagent-codex/driver.ts | 51 ++++++ .../subagent/subagent-codex/fixture.ts | 100 +--------- .../codex/evidence.expected.json | 38 ---- .../codex/session.expected.jsonl | 25 --- .../subagent-product-providers.snapshot.ts | 171 ------------------ examples/package.json | 1 - knip.json | 4 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 8 +- packages/subagent/subagent-codex/README.zh.md | 8 +- packages/subagent/subagent-codex/package.json | 1 + packages/subagent/subagent-codex/src/wire.ts | 29 ++- .../tests/loader-composition.e2e.ts | 53 ++++++ .../subagent-codex/tests/real-product.spec.ts | 13 +- .../tests/subagent-codex.spec.ts | 65 ++++++- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 1 + packages/subagent/subagent/README.zh.md | 1 + pnpm-lock.yaml | 6 +- scripts/gen-doc-graphs.ts | 6 +- 31 files changed, 270 insertions(+), 405 deletions(-) create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts delete mode 100644 examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json delete mode 100644 examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl delete mode 100644 examples/acp-agent/tests/subagent-product-providers.snapshot.ts create mode 100644 packages/subagent/subagent-codex/tests/loader-composition.e2e.ts diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 8431b6bbce..bde3f3cf11 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28 +2026-08-04-claude-code-and-codex-subagent-backends.md: 3b9fd51632439da5b3c3fd9187de552d6c9ca5e2 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 36be903640ad1c839d45ed1bf5e605f4e4d6e000 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 37f45f844c..3b9fd51632 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -8,15 +8,15 @@ English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. They must also prove the real assembled path in required keyless tests. A direct model HTTP request, product double, or hand-mounted plugin cannot show that the Loader, fixed tool, provider registration, official product protocol, native authentication shape, final answer, and teardown work together. +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official protocol, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot replace the former; a hand-mounted plugin cannot replace the latter. ## Proposal -The harness provides two sibling one-shot providers behind two fixed model-facing tools. `subagent_codex` selects the `codex` provider, and `subagent_claude_code` selects the `claude-code` provider. Each tool accepts only a standalone text task and binds its provider at deployment time; product selection and background execution are not model arguments. +The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text fixed tool → shared subagent service → product provider → official product process @@ -30,7 +30,7 @@ fixed tool → shared subagent service → product provider → official product | --- | --- | --- | --- | | Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published | | Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects | -| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive one shared `completed`, `error`, or `aborted` result | The parent receives only a final answer or an explicit failure | +| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive an existing shared stop reason; Codex uses `max-tokens` only for explicit context exhaustion | The parent receives only a final answer or an explicit failure | | Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits | ## Codex provider @@ -39,9 +39,9 @@ fixed tool → shared subagent service → product provider → official product Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A completed turn without an answer, a failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`. Local cancellation wins its race and remains `aborted`. +`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. -The unattended wire declines command and file approvals, grants no requested permissions for the turn, and declines MCP elicitation. Any other server request fails the run instead of waiting for a user interface the provider does not supply. +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. @@ -51,11 +51,11 @@ The Claude Code sibling is not yet implemented. Its product version, official in ## Evidence contract -Each product owns branch-complete package tests, a required real-product spec, and a real Loader snapshot. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. +Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. -The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, complete persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Its Loader e2e resolves `@deepseek-ai/dsh-subagent-codex` by package name, verifies the `codex` registration and `subagent_codex` schema with background omitted, accepts `maxDepth: 'provider-managed'`, and records zero child starts while no `codex` command is available. The npm package is a development dependency for reproducible real-product evidence; production still supplies `codex` on `PATH`. -The combined contract is complete only when the Claude sibling has equivalent real-product evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract. +The combined contract is complete only when the Claude sibling has equivalent real-product evidence and both public Loader configurations prove the fixed tools use the unchanged common subagent contract. ## Alternatives considered @@ -73,7 +73,7 @@ The combined contract is complete only when the Claude sibling has equivalent re ## Acceptance criteria -Both fixed tools reach their corresponding real products through the Loader, return exact final answers or explicit failure or cancellation, persist the complete model-visible parent transcript, and prove managed process-tree quiescence in required keyless CI. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. +Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required real-product specs return exact final answers or explicit failure or cancellation and prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index dcd7b90231..36be903640 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -8,15 +8,15 @@ Status: proposed 命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 -产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。它们还必须在强制性的无密钥测试中证明真实组装路径。直接发起模型 HTTP 请求、使用产品替身或手工挂载插件,都无法证明 Loader、固定工具、提供方注册、官方产品协议、原生身份验证形态、最终回答和资源清理能够协同工作。 +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据会分别证明两个事实:真实产品测试证明官方协议、原生身份验证形态、最终回答和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品。直接发起模型 HTTP 请求或使用产品替身无法取代前者,手工挂载插件则无法取代后者。 ## 提案 -harness 在两个固定的面向模型工具背后提供两个一次性兄弟提供方。`subagent_codex` 选择 `codex` 提供方,`subagent_claude_code` 选择 `claude-code` 提供方。每个工具只接受独立文本任务,并在部署时绑定其提供方;产品选择与后台执行都不作为模型参数。 +harness 将两个一次性兄弟提供方发布为可独立安装的可选包。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,而 `subagent_claude_code` 绑定 `claude-code`。正式 CLI 的依赖闭包以及基础、Web 和 headless 配置都不加载这两个提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。文档中的工具会关闭后台执行并使用 `maxDepth: 'provider-managed'`,让进程外产品自行负责递归策略,而不会向提供方发送其无法执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text fixed tool → shared subagent service → product provider → official product process @@ -30,7 +30,7 @@ fixed tool → shared subagent service → product provider → official product | --- | --- | --- | --- | | 解析 | `dsh-tool-subagent` 与 `ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 | | 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 | -| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导一个共享的 `completed`、`error` 或 `aborted` 结果 | 父级只会收到最终回答或明确失败 | +| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导出一种现有的共享停止原因;Codex 仅在明确发生上下文耗尽时使用 `max-tokens` | 父级只会收到最终回答或明确失败 | | dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 | ## Codex 提供方 @@ -39,9 +39,9 @@ fixed tool → shared subagent service → product provider → official product 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。轮次完成却没有答案、远端轮次失败或中断、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`。本地取消在竞态中胜出并保持为 `aborted`。 +`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 -无人值守的协议连接会拒绝命令与文件审批,不授予该轮次请求的任何权限,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方没有提供的用户界面。 +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 @@ -51,11 +51,11 @@ Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本 ## 证据契约 -每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一个真实 Loader 快照。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。 +每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一项 Loader 装配 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。 -Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader 快照锁定不支持后台执行的工具 schema、确切的工具调用与结果、完整的已持久化父会话、产品请求,以及清理前的完全停稳状态。该 NPM 包是用于复现证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader e2e 会按包名解析 `@deepseek-ai/dsh-subagent-codex`,验证 `codex` 注册与省略后台参数的 `subagent_codex` schema,接受 `maxDepth: 'provider-managed'`,并在环境中没有可用 `codex` 命令时记录零次子级启动。该 NPM 包是用于复现真实产品证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 -只有在 Claude 兄弟提供方具备同等的真实产品证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。 +只有在 Claude 兄弟提供方具备同等的真实产品证据,并且两个公开 Loader 配置都证明固定工具使用未变的通用 subagent 契约时,组合契约才算完整。 ## 曾考虑的替代方案 @@ -73,7 +73,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 ## 验收标准 -两个固定工具都通过 Loader 到达相应的真实产品,返回完全一致的最终回答或明确的失败或取消结果,持久化完整的模型可见父级 transcript,并在强制性的无密钥 CI 中证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 +两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,并证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 44af0b5e76..c64e678b54 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -104,6 +104,7 @@ flowchart LR pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] pkg_subagent_codex["subagent-codex"] + pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] @@ -225,6 +226,7 @@ flowchart LR pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_codex --> svc_subagents + pkg_subagent_dsh_sdk --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents pkg_subprocess --> svc_subprocess @@ -314,6 +316,7 @@ flowchart LR svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp svc_subprocess --> pkg_subagent_codex + svc_subprocess --> pkg_subagent_dsh_sdk svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -373,7 +376,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | - | The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | @@ -384,7 +387,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index f2438bea7a..0582fb9dac 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 379bd2644a4a7de005c8ea56d4857b6a6f9143b8 -extension-cookbook.zh.md: 09a5163c3b47e52e0e0e88f000a8f04302fa93e4 +extension-cookbook.md: 820d7fce8560028f592ec101f4f222013105f035 +extension-cookbook.zh.md: bb48f584729ad3a67fd43ca8c4c9f6f4902de656 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 379bd2644a..820d7fce85 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -118,7 +118,7 @@ Every product feature maps to a listener on a documented extension seam — the | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | -| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`) + `dsh-tool-subagent` exposing one configured provider to the model | +| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-dsh-sdk`) + `dsh-tool-subagent` exposing one configured provider to the model | | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 09a5163c3b..bb48f58472 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -118,7 +118,7 @@ export function apply(ctx: Context) { | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | -| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | +| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-dsh-sdk`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d5682a47bc..6bd775a84c 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: 917913470da389dccac83e455cf23486a94c23b1 -subagent.zh.md: efe12a5a5fe40d664f3071036157acd704b10c57 +subagent.md: c810ae40f57a0f84f1a6b53e092d6bec88af206f +subagent.zh.md: efeec0a0f20b7ac85020df012878cf41f264073d diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 917913470d..c810ae40f5 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index efe12a5a5f..efeec0a0f2 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为四个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为五个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index f219214dd0..be1af7c135 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -1,5 +1,5 @@ -# Test-only composition: one real Codex app-server delegation through the -# Loader, fixed provider tool, common foreground settlement, and JSONL store. +# Test-only composition of the public opt-in provider and foreground tool. +# The owning e2e boots this tree but never invokes the model or Codex. - id: fixture name: './fixture.ts' @@ -11,17 +11,6 @@ - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' - config: - env: - OPENAI_API_KEY: !!js process.env.DSH_TEST_OPENAI_API_KEY - CODEX_HOME: !!js process.env.DSH_TEST_CODEX_HOME - HOME: !!js process.cwd() - XDG_CONFIG_HOME: !!js process.cwd() + '/xdg' - PATH: !!js process.env.PATH - HTTP_PROXY: '' - HTTPS_PROXY: '' - ALL_PROXY: '' - NO_PROXY: '127.0.0.1,localhost' - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' @@ -36,7 +25,5 @@ config: provider: mock model: mock-delegate - persona: 'Delegate the task through the fixed Codex tool.' - persistenceRoot: './.sessions' - persistenceCompression: 'none' + persona: 'This composition test must not start a model turn.' workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts new file mode 100644 index 0000000000..873f5e36de --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** Inspect the public Codex provider composition without invoking the product. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-tools' + +const configPath = process.argv[2] +if (configPath === undefined) { + throw new Error('subagent-codex Loader composition driver requires a config path') +} + +let starts = 0 +const ctx = await boot( + 'subagent-codex-loader-composition', + resolveConfigPath(configPath, undefined), + undefined, + (hostCtx) => { + hostCtx.on('subagent/start', () => { + starts += 1 + }) + }, +) + +try { + const provider = ctx.subagents.getProvider('codex') + if (provider === undefined) throw new Error('Codex provider was not registered') + const tool = ctx.tools.schemas().find(schema => schema.name === 'subagent_codex') + if (tool === undefined) throw new Error('subagent_codex tool was not registered') + const properties = tool.parameters.properties + if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) { + throw new Error('subagent_codex tool has invalid parameter properties') + } + + process.stdout.write(`${JSON.stringify({ + providers: ctx.subagents.list(), + provider: { + name: provider.name, + capabilities: provider.capabilities, + inheritsParentContext: provider.inheritsParentContext, + }, + tool: { + name: tool.name, + parameterNames: Object.keys(properties).sort(), + required: tool.parameters.required, + }, + starts, + })}\n`) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts index 9618c83654..e2dc946dfe 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts @@ -1,102 +1,22 @@ -/** Deterministic parent model and process-quiescence observer for the Codex Loader snapshot. */ +/** Parent adapter that fails if the composition-only Loader test starts a turn. */ -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' import type { Context } from 'cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { - SubprocessHandle, - SubprocessSpawnSpec, -} from '@deepseek-ai/dsh-subprocess' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' -const CODEX_TASK = 'Return the Loader snapshot sentinel exactly.' -const QUIESCENCE_FILE = '.codex-quiescence.json' - -function toolResultText(options: GenerateOptions): string { - return options.messages.at(-1)?.content - .filter(block => block.type === 'tool-result') - .flatMap(block => block.content) - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') ?? '' -} - -class CodexDelegatingAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { - const result = toolResultText(options) - if (result.length === 0) { - const args = JSON.stringify({ - description: 'Codex Loader snapshot', - prompt: CODEX_TASK, - }) - yield { type: 'block-start', index: 0, blockType: 'tool-call' } - yield { - type: 'tool-call-delta', - index: 0, - id: CallId('call-codex-loader'), - name: 'subagent_codex', - argumentsDelta: args, - } - yield { - type: 'block-end', - index: 0, - block: { - type: 'tool-call', - id: CallId('call-codex-loader'), - name: 'subagent_codex', - arguments: args, - }, - } - yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } - return - } - - const reply = `Codex child returned: ${result}` - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text: reply } - yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } - yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } - yield { type: 'finish', reason: { kind: 'stop' } } +class CompositionOnlyAdapter extends LlmAdapter { + async * stream(_options: GenerateOptions): AsyncIterable { + throw new Error('subagent-codex Loader composition must not invoke a model') } } -interface ObservedProcess { - readonly spec: SubprocessSpawnSpec - readonly handle: SubprocessHandle -} - -export const name = 'codex-loader-snapshot-fixture' -export const inject = ['llm', 'subprocess'] +export const name = 'codex-loader-composition-fixture' +export const inject = ['llm'] /** - * Register the deterministic parent adapter and record whether every spawned - * product tree was already quiet when the assembled application disposed. - * @param ctx - Loader context supplying the LLM and subprocess seams. + * Register a parent adapter solely so the host composition is complete. + * @param ctx - Loader context supplying the LLM seam. */ export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['mock'], new CodexDelegatingAdapter()) - ctx.effect(() => { - const observed: ObservedProcess[] = [] - const originalSpawn = ctx.subprocess.spawn.bind(ctx.subprocess) - ctx.subprocess.spawn = (spec: SubprocessSpawnSpec): SubprocessHandle => { - const handle = originalSpawn(spec) - observed.push({ spec, handle }) - return handle - } - return async () => { - ctx.subprocess.spawn = originalSpawn - const alreadyExited = AbortSignal.abort() - const processes = await Promise.all(observed.map(async ({ spec, handle }) => ({ - argv: [...spec.argv], - quiescent: await handle.waitForExit(alreadyExited), - outcome: await handle.done, - }))) - await writeFile( - join(process.cwd(), QUIESCENCE_FILE), - `${JSON.stringify({ processes })}\n`, - ) - } - }, 'codex Loader snapshot process observer') + ctx.llm.registerAdapter(['mock'], new CompositionOnlyAdapter()) } diff --git a/examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json b/examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json deleted file mode 100644 index f6f9b995ae..0000000000 --- a/examples/acp-agent/tests/product-provider-snapshots/codex/evidence.expected.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "stdout": { - "type": "result", - "success": true, - "sessionId": "{{sessionId}}", - "turn": 1, - "result": "Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0", - "reason": { - "kind": "completed" - }, - "usage": { - "inputTokens": 20, - "outputTokens": 61 - } - }, - "request": { - "method": "POST", - "path": "/v1/responses", - "authorization": "Bearer dsh-fake-openai-loader-key", - "taskObserved": true - }, - "quiescence": { - "processes": [ - { - "argv": [ - "codex", - "app-server", - "--stdio" - ], - "quiescent": true, - "outcome": { - "exitCode": 0, - "signal": null - } - } - ] - } -} diff --git a/examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl b/examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl deleted file mode 100644 index e15b81ddf0..0000000000 --- a/examples/acp-agent/tests/product-provider-snapshots/codex/session.expected.jsonl +++ /dev/null @@ -1,25 +0,0 @@ -{"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":"Delegate through Codex once."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Delegate through Codex once.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"mock","model":"mock-delegate"},"system":"{{system}}","tools":[{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent_codex","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}}]},"reason":"initial"}} -{"type":"request/context","seq":5,"time":0,"data":{"provider":"mock","model":"mock-delegate"}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-codex-loader","name":"subagent_codex","argumentsDelta":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} -{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call-codex-loader","name":"subagent_codex","arguments":"{\"description\":\"Codex Loader snapshot\",\"prompt\":\"Return the Loader snapshot sentinel exactly.\"}"}} -{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-codex-loader"},"content":[{"type":"tool-result","toolCallId":"call-codex-loader","content":[{"type":"text","text":"REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":56}}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Codex child returned: REAL_CODEX_LOADER_SENTINEL_0_146_0"}],"source":{"kind":"model","provider":"mock","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":56}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts b/examples/acp-agent/tests/subagent-product-providers.snapshot.ts deleted file mode 100644 index 64d1d244ae..0000000000 --- a/examples/acp-agent/tests/subagent-product-providers.snapshot.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Real-product Loader snapshots for fixed subagent providers. - * - * PR1 owns the Codex scenario. PR2 extends this file with the sibling Claude - * Code scenario and reruns both from its final stacked candidate. - */ - -import { homedir } from 'node:os' -import { dirname, delimiter, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { describe, expect, it } from 'vitest' -import { - normalizeSessionLog, - normalizeStdout, - scrubSystemPrompts, - type NormalizeContext, -} from '@deepseek-ai/dsh-acp-snapshot' -import { - LOADER_SMOKE_TEST_TIMEOUT_MS, - runLoaderSmoke, -} from '@deepseek-ai/dsh-loader-smoke' -import { startResponsesFixture } from '../../../packages/subagent/subagent-codex/tests/responses-fixture.ts' - -const testsDir = dirname(fileURLToPath(import.meta.url)) -const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) -const fixtureDir = join(testsDir, 'fixtures/subagent/subagent-codex') -const configPath = join(fixtureDir, 'cordis.yml') -const snapshotDir = join(testsDir, 'product-provider-snapshots/codex') -const sessionExpected = join(snapshotDir, 'session.expected.jsonl') -const evidenceExpected = join(snapshotDir, 'evidence.expected.json') -const cliBin = join(repoRoot, 'packages/examples/cli-demo/src/bin.ts') -const repoTsconfig = join(repoRoot, 'tsconfig.json') -const codexBinDir = join( - repoRoot, - 'packages/subagent/subagent-codex/node_modules/.bin', -) -const refreshing = process.env.DSH_SNAPSHOT === 'refresh' -const CODEX_SENTINEL = 'REAL_CODEX_LOADER_SENTINEL_0_146_0' -const FAKE_KEY = 'dsh-fake-openai-loader-key' - -interface PersistedSession { - readonly content: string - readonly header: { - readonly id: string - readonly cwd: string - } -} - -async function onlySession(root: string): Promise { - const paths = (await readdir(root, { recursive: true })) - .filter(path => path.endsWith('.jsonl')) - expect(paths).toHaveLength(1) - const path = paths[0] - if (path === undefined) throw new Error('Codex Loader snapshot persisted no session') - const content = await readFile(join(root, path), 'utf8') - const header = JSON.parse(content.slice(0, content.indexOf('\n'))) as PersistedSession['header'] - return { content, header } -} - -function responseInputTexts(body: Record): string[] { - if (!Array.isArray(body.input)) return [] - return body.input.flatMap((item): string[] => { - if (item === null || typeof item !== 'object') return [] - const content = (item as Record).content - if (!Array.isArray(content)) return [] - return content.flatMap((part): string[] => ( - part !== null - && typeof part === 'object' - && typeof (part as Record).text === 'string' - ? [(part as Record).text as string] - : [] - )) - }) -} - -describe('real product subagent providers through the Loader', () => { - it('pins the Codex tool, result, persisted Session, and process quiescence', async () => { - const codexHome = await mkdtemp(join(homedir(), '.dsh-subagent-codex-loader-')) - const responses = await startResponsesFixture([ - { kind: 'complete', text: CODEX_SENTINEL }, - ]) - let session: PersistedSession | undefined - let quiescence: unknown - try { - const result = await runLoaderSmoke({ - label: 'Codex subagent Loader snapshot', - tempDirPrefix: 'dsh-subagent-codex-loader-', - binScript: cliBin, - configPath, - binArgs: [ - '--config', - configPath, - '--output-format', - 'json', - 'Delegate through Codex once.', - ], - tsconfigPath: repoTsconfig, - processTimeoutMs: 45_000, - env: { - DSH_TEST_CODEX_HOME: codexHome, - DSH_TEST_OPENAI_API_KEY: FAKE_KEY, - PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, - }, - async prepare(): Promise { - await writeFile(join(codexHome, 'config.toml'), [ - 'model = "fixture-model"', - 'model_provider = "fixture"', - 'approval_policy = "on-request"', - 'sandbox_mode = "read-only"', - 'disable_response_storage = true', - 'check_for_update_on_startup = false', - '', - '[model_providers.fixture]', - 'name = "Fixture Responses"', - `base_url = "${responses.baseUrl}"`, - 'env_key = "OPENAI_API_KEY"', - 'wire_api = "responses"', - 'requires_openai_auth = false', - '', - '[analytics]', - 'enabled = false', - '', - ].join('\n')) - }, - async inspect(cwd): Promise { - session = await onlySession(join(cwd, '.sessions')) - quiescence = JSON.parse(await readFile(join(cwd, '.codex-quiescence.json'), 'utf8')) - }, - }) - - expect(result.stderr).toBe('') - expect(session).toBeDefined() - if (session === undefined) throw new Error('Codex Loader snapshot session was not inspected') - const context: NormalizeContext = { - sessionIds: [session.header.id], - cwd: session.header.cwd, - } - const normalizedSession = scrubSystemPrompts(normalizeSessionLog(session.content, context)) - const request = responses.requests[0] - expect(request).toBeDefined() - if (request === undefined) throw new Error('Codex Loader snapshot made no Responses request') - const evidence = `${JSON.stringify({ - stdout: JSON.parse(normalizeStdout(result.stdout, context)) as unknown, - request: { - method: request.method, - path: request.path, - authorization: request.headers.authorization, - taskObserved: responseInputTexts(request.body) - .includes('Return the Loader snapshot sentinel exactly.'), - }, - quiescence, - }, null, 2)}\n` - - if (refreshing) { - await mkdir(snapshotDir, { recursive: true }) - await Promise.all([ - writeFile(sessionExpected, normalizedSession), - writeFile(evidenceExpected, evidence), - ]) - } - expect(normalizedSession).toBe(await readFile(sessionExpected, 'utf8')) - expect(evidence).toBe(await readFile(evidenceExpected, 'utf8')) - } finally { - await Promise.all([ - responses.close(), - rm(codexHome, { recursive: true, force: true }), - ]) - } - }, LOADER_SMOKE_TEST_TIMEOUT_MS + 30_000) -}) diff --git a/examples/package.json b/examples/package.json index 83e20a5b2b..bc697ab11c 100644 --- a/examples/package.json +++ b/examples/package.json @@ -67,7 +67,6 @@ "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", - "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", diff --git a/knip.json b/knip.json index 75909cc8d3..1d99ae4cd4 100644 --- a/knip.json +++ b/knip.json @@ -46,6 +46,7 @@ "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", + "acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", @@ -543,7 +544,8 @@ }, "packages/subagent/subagent-codex": { "entry": [ - "tests/**/*.spec.ts" + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index bee793e09a..3e8e805c88 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent-codex/README.md -README.md: ca92f935539812351dd578dca700c9a0113dcd46 -README.zh.md: 6f6690ea51970dd39c738ad0ec4f55c2a5ab2467 +README.md: ce1c66427b562c08af06320f012f28b9e125ac45 +README.zh.md: bef47586db77c70bec741629d8579ba0e2efba1e diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index ca92f93553..ce1c66427b 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -10,9 +10,9 @@ This package registers the fixed `codex` subagent provider. Each accepted run st The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. -The unattended provider answers command and file approvals with `decline`, answers permission requests with an empty turn-scoped permission set, and declines MCP elicitation. Any other server request fails the run instead of waiting for interaction that this provider cannot supply. +For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. -Local cancellation wins the result race and maps to `aborted`; a remote interrupted or failed turn maps to `error`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` when the current ids are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and this version produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. ## Capabilities and context @@ -27,6 +27,8 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. +Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default. + ```yaml - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' @@ -45,7 +47,7 @@ Production resolves `codex` from `PATH` and uses the host's native Codex configu ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: package tests drive the real binary against a loopback Responses service with a non-empty fake key, and the Loader snapshot fixes the model-visible tool schema, exact tool result, persisted parent Session, original child task, authentication header, and pre-teardown process-tree quiescence. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. ## Model Experience diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 6f6690ea51..bef47586db 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -10,9 +10,9 @@ 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 -无人值守的提供方对命令与文件审批答复 `decline`,对权限请求返回作用域限于当前轮次的空权限集,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方无法提供的交互。 +对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 -本地取消会在结果竞态中胜出并映射为 `aborted`;远端轮次若中断或失败,则映射为 `error`。`dispose()` 具有幂等性:如果当前标识符已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且本版本不会产生 `refusal`。`dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 ## 能力与上下文 @@ -27,6 +27,8 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 +请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`。 + ```yaml - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' @@ -45,7 +47,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:包测试使用非空的伪密钥,驱动真实二进制程序连接回环 Responses 服务;Loader 快照则锁定模型可见的工具 schema、确切的工具结果、已持久化的父会话、原始子任务、身份验证请求头,以及清理前进程树的完全停稳状态。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 ## 模型体验 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index ea4a2a2e45..10bf7ee5f4 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 9d15f7c6d1..4e920113e3 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -39,6 +39,25 @@ function string(value: unknown, label: string): string { return value } +function unattendedDecision(params: JsonObject): 'cancel' | 'decline' { + const available = params.availableDecisions + if (available === undefined || available === null) return 'decline' + if (Array.isArray(available)) { + if (available.includes('cancel')) return 'cancel' + if (available.includes('decline')) return 'decline' + } + throw new Error('subagent-codex: app-server offered no unattended approval decision') +} + +function isContextWindowExceeded(turn: JsonObject): boolean { + if (turn.status !== 'failed') return false + const error = turn.error + return error !== null + && typeof error === 'object' + && !Array.isArray(error) + && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded' +} + function thrown(value: unknown): Error { /* v8 ignore next -- typed protocol and stream failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -160,7 +179,7 @@ export class CodexAppServerWire { * @param texts - already validated task text blocks. * @param signal - local cancellation for the published run. * @param cancelled - whether local cancellation has already won. - * @returns the shared three-state subagent result. + * @returns the shared subagent result. */ async runTurn( texts: readonly string[], @@ -182,6 +201,9 @@ export class CodexAppServerWire { const terminal = object(completed.turn, 'turn/completed turn') const status = terminal.status + if (isContextWindowExceeded(terminal)) { + return { output: this.collectOutput(), stopReason: 'max-tokens' } + } if (status !== 'completed') { const detail = status === 'failed' ? `: ${JSON.stringify(terminal.error)}` @@ -292,10 +314,13 @@ export class CodexAppServerWire { case 'item/commandExecution/requestApproval': case 'item/fileChange/requestApproval': this.validateRunIds(params) - return Promise.resolve({ decision: 'decline' }) + return Promise.resolve({ decision: unattendedDecision(params) }) case 'item/permissions/requestApproval': this.validateRunIds(params) return Promise.resolve({ permissions: {}, scope: 'turn' }) + case 'item/tool/requestUserInput': + this.validateRunIds(params) + return Promise.resolve({ answers: {} }) case 'mcpServer/elicitation/request': this.validateRunIds(params, true) return Promise.resolve({ action: 'decline', content: null, _meta: null }) diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..6c4019f8c8 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -0,0 +1,53 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-codex/', + import.meta.url, +)) +const driver = join(fixtureDir, 'driver.ts') +const configPath = join(fixtureDir, 'cordis.yml') +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +describe('Codex provider public Loader composition', () => { + it('loads the opt-in package and foreground tool without starting Codex', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'subagent-codex Loader composition', + tempDirPrefix: 'dsh-subagent-codex-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + // Loading the optional package must not probe or start a Codex binary. + PATH: '', + }, + }) + + expect(stderr).toBe('') + expect(JSON.parse(stdout)).toEqual({ + providers: ['codex'], + provider: { + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + tool: { + name: 'subagent_codex', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + starts: 0, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 77c494f762..dd7c0c458f 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -172,8 +172,7 @@ describe('real @openai/codex 0.146.0 product', () => { await expectQuiescent(harness.handles) }, 20_000) - it('declines a real app-server command approval without executing the command', async () => { - const sentinel = 'REAL_CODEX_APPROVAL_DECLINED' + it('cancels a real app-server command approval without executing the command', async () => { const { harness, fixture } = await realHarness([ { kind: 'functionCall', @@ -184,7 +183,6 @@ describe('real @openai/codex 0.146.0 product', () => { justification: 'exercise the unattended approval boundary', }, }, - { kind: 'complete', text: sentinel }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') const run = await harness.ctx.subagents.start('codex', { @@ -193,20 +191,17 @@ describe('real @openai/codex 0.146.0 product', () => { signal: new AbortController().signal, }) await expect(run.result).resolves.toEqual({ - output: [{ type: 'text', text: sentinel }], - stopReason: 'completed', + output: [], + stopReason: 'error', }) await run.dispose() expect(existsSync(sideEffect)).toBe(false) - expect(fixture.requests).toHaveLength(2) + expect(fixture.requests).toHaveLength(1) const tools = fixture.requests[0]!.body.tools as Array> expect(tools).toEqual(expect.arrayContaining([ expect.objectContaining({ type: 'function', name: 'exec_command' }), ])) - const followup = JSON.stringify(fixture.requests[1]!.body) - expect(followup).toContain('call_fixture') - expect(followup).toContain('rejected by user') expect(fixture.requests.every(requestEntry => requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', )).toBe(true) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 66d5ef5d5c..6cc4461e06 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -417,6 +417,25 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('maps only an explicit context-window failure to max-tokens', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'too much context', + codexErrorInfo: 'contextWindowExceeded', + }), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + stopReason: 'max-tokens', + }) + wire.close() + }) + it('rejects invalid handshake, thread, and turn response shapes', async () => { { const child = fakeChild() @@ -516,7 +535,7 @@ describe('CodexAppServerWire', () => { wire.close() }) - it('answers all four unattended request classes without granting authority', async () => { + it('answers all five unattended request classes without granting authority', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal, () => false) const turnStart = await child.peer.nextMethod('turn/start') @@ -524,10 +543,14 @@ describe('CodexAppServerWire', () => { child.peer.send({ id: 'command', method: 'item/commandExecution/requestApproval', - params: { threadId: 'thread-1', turnId: 'turn-1' }, + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline', 'cancel'], + }, }) expect(await child.peer.nextResponse('command')).toMatchObject({ - result: { decision: 'decline' }, + result: { decision: 'cancel' }, }) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -536,6 +559,16 @@ describe('CodexAppServerWire', () => { { id: 'file', method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + result: { decision: 'decline' }, + }, + { + id: 'file-default', + method: 'item/fileChange/requestApproval', params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { decision: 'decline' }, }, @@ -545,6 +578,12 @@ describe('CodexAppServerWire', () => { params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { permissions: {}, scope: 'turn' }, }, + { + id: 'user-input', + method: 'item/tool/requestUserInput', + params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] }, + result: { answers: {} }, + }, { id: 'mcp', method: 'mcpServer/elicitation/request', @@ -568,9 +607,27 @@ describe('CodexAppServerWire', () => { for (const serverRequest of [ { id: 'unknown', - method: 'item/tool/requestUserInput', + method: 'future/request', params: { threadId: 'thread-1', turnId: 'turn-1' }, }, + { + id: 'approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['accept'], + }, + }, + { + id: 'malformed-approval', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: 'decline', + }, + }, { id: 'thread', method: 'item/fileChange/requestApproval', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 15873129cd..98763ddb6e 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/README.md -README.md: 4040f9a48bd61cc230adec1bd9725cf30bdfd8f7 -README.zh.md: 5f6a041887e3227d92a88eac344524e55a598413 +README.md: 4682b06ae105a0ae70ea7e78a80776ac18d817e7 +README.zh.md: c39afb26d8c6baf4774ae3b4a8f8151529a29e15 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4040f9a48b..4682b06ae1 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -15,6 +15,7 @@ The family separates the stable interface from implementations and model-facing | `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | | `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). | +| `@deepseek-ai/dsh-subagent-dsh-sdk` | Fresh out-of-process harness child driven through the TypeScript SDK client (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | | `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. | diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 5f6a041887..c39afb26d8 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -15,6 +15,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | | `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 | +| `@deepseek-ai/dsh-subagent-dsh-sdk` | 通过 TypeScript SDK 客户端驱动的全新进程外 harness 子 agent(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | | `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 237b8296c4..352a8a2abf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,9 +724,6 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn - '@deepseek-ai/dsh-subprocess': - specifier: workspace:* - version: link:../packages/subprocess/subprocess '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../packages/subprocess/subprocess-local @@ -4969,6 +4966,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ version: link:../../sdk/sdk-protocol diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 804dbbddf4..7c3c8261fe 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex'], - note: 'The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], + note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', @@ -416,7 +416,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider and continuation service', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, From 207c45d15f5486bcf958a0c01c0ea5bab8192a49 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 19:55:27 +0800 Subject: [PATCH 014/179] fix(workspace-context): deduplicate baseline on resume --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 10 +- .../2026-06-24-workspace-context.zh.md | 10 +- docs/event-producer-consumer.md | 2 +- .../workspace-context-resume-agent.ts | 25 +++ .../offline-edit/replay.jsonl | 1 + .../offline-edit/replay.override.json | 11 ++ .../offline-edit/session.expected.jsonl | 20 +++ .../workspace-context-resume.snapshot.ts | 151 ++++++++++++++++++ ...rkspace-context-resume.cordis.snapshot.yml | 37 +++++ .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 10 +- .../context/workspace-context/README.zh.md | 10 +- .../context/workspace-context/src/index.ts | 14 +- .../context/workspace-context/src/state.ts | 2 +- .../tests/workspace-context.spec.ts | 100 ++++++++++-- 16 files changed, 358 insertions(+), 53 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts create mode 100644 examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.jsonl create mode 100644 examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.override.json create mode 100644 examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl create mode 100644 examples/headless-agent/tests/workspace-context-resume.snapshot.ts create mode 100644 examples/headless-agent/workspace-context-resume.cordis.snapshot.yml diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 2199cc6f6b..c127e30f7c 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-24-workspace-context.md -2026-06-24-workspace-context.md: d58dde38b9851bc1cad2e6b4c679343dedfbc139 -2026-06-24-workspace-context.zh.md: 983b6a82df78cb865cf9db367cba0fd839c247e9 +2026-06-24-workspace-context.md: a9d11f88ab9525a40f9bc58f817a088edc43105a +2026-06-24-workspace-context.zh.md: 1273bd9b460055a4b0e193267c5e9ad37bbeb0a2 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index d58dde38b9..a9d11f88ab 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -28,11 +28,11 @@ The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by eithe ### Baseline Injection -At the first `agent/step` of an agent-loop instance, the plugin injects one sourced user-role message before the request is derived. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. +At the first `agent/step` of a fresh session, the plugin injects one sourced user-role message before the request is derived. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. -The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. +The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes a complete baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. -A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. +A resumed agent creates a new loop instance over persisted history. If a typed baseline remains in the visible surface, the loop retains that event and reconciles baseline plus dynamic scopes against current files before its first request. Unchanged files append nothing; files added, edited, or removed while the agent was offline append `set`, `replace`, or `remove` transitions without mutating or duplicating the original baseline. A hot plugin remount follows the same visibility rule. If no typed baseline remains visible, as after compaction shadows it, the loop composes and injects one complete current baseline. Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, but restores only for an assembly explicitly marked for the loop's next model request; diagnostic assemblies remain read-only. When a prior typed baseline exists but none remains visible, the listener recomposes the current chain, rechecks cancellation and the current surface generation after every asynchronous probe, and injects before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker plus the synchronous commit-time recheck lets concurrent preparations scan without queuing duplicate baselines. @@ -54,9 +54,9 @@ Every workspace context event stores versioned metadata with `{ action, scope, p At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. -An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes a dynamic instruction event from the visible surface, that state no longer suppresses a later tool-triggered load; if it removes the baseline, prompt assembly restores the complete current chain before the next request. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. +An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata: a visible baseline is comparison state for current-file reconciliation rather than a reason to append another complete baseline. If compaction removes a dynamic instruction event from the visible surface, that state no longer suppresses a later tool-triggered load; if it removes the baseline, prompt assembly restores the complete current chain before the next request. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. -The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Model-request prompt assembly recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request. It rechecks the caller's signal before injection, so an aborted preparation publishes no pending baseline; a queued marker remains until the corresponding durable event confirms delivery. Later successful filesystem touches can append edits or removals as dynamic messages. The plugin never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed or post-replacement baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the next request. +The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A resumed loop retains that baseline and reconciles current baseline and visible dynamic scopes, so changes made while the agent was offline append transitions before the next request. Model-request prompt assembly instead recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request. It rechecks the caller's signal before injection, so an aborted preparation publishes no pending baseline; a queued marker remains until the corresponding durable event confirms delivery. The plugin never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch, post-replacement prompt assembly, or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 983b6a82df..1273bd9b46 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -28,11 +28,11 @@ Status: implemented ### 基线注入 -在 agent loop(智能体循环)实例的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 +在全新会话的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 -该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 +该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 -恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 +恢复 agent 会基于持久化历史创建新的 loop 实例。如果带类型的基线仍位于可见表层,loop 会保留该事件,并在第一个请求前根据当前文件对账基线与动态 scope。未变文件不追加任何内容;agent 离线期间新增、编辑或移除的文件会追加 `set`、`replace` 或 `remove` 转换,既不改写也不重复追加原始基线。插件热重挂遵循相同的可见性规则。如果已无带类型的基线可见(例如压缩(compaction)将其遮蔽后),loop 会组合并注入一条完整的当前基线。 在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,但只有当组装被明确标记为供 loop 的下一个模型请求使用时才恢复;诊断组装保持只读。如果此前存在带类型的基线、但已无基线可见,该监听器会重新组合当前文件链,在每次异步探测后重新检查取消状态和当前表层代次,并在 loop 排空 outbox 和对派生请求历史创建快照之前注入。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记加上提交时同步复查,使并发准备可以扫描而不会排入重复基线。 @@ -54,9 +54,9 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除动态指令事件,该状态不再抑制之后由工具触发的加载;如果移除的是基线,提示词组装会在下一个请求前恢复完整的当前指令链。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复从持久化元数据继续工作:可见基线是当前文件对账的比较状态,而不是追加另一条完整基线的理由。如果压缩从可见表面移除动态指令事件,该状态不再抑制之后由工具触发的加载;如果移除的是基线,提示词组装会在下一个请求前恢复完整的当前指令链。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 -只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。面向模型请求的提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它。它会在注入前重新检查调用方的 signal,因此已中止的准备不会发布待处理基线;排队标记会保留,直到相应的持久事件确认投递。之后成功的文件系统触碰仍可把编辑或移除作为动态消息追加。插件绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。在恢复或替换后准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在下一个请求前追加更新。 +只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。恢复的 loop 会保留该基线,并对账当前基线与可见动态 scope,因此 agent 离线期间的变更会在下一个请求前追加为转换。而面向模型请求的提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它。它会在注入前重新检查调用方的 signal,因此已中止的准备不会发布待处理基线;排队标记会保留,直到相应的持久事件确认投递。插件绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。 系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰、替换后的提示词组装或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ce019cfe9f..26db842db8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/step` | `serial` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts b/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts new file mode 100644 index 0000000000..4cdf7d157a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts @@ -0,0 +1,25 @@ +/** + * Loader fixture that resumes the seeded workspace-context session. + * @module workspace-context-resume-agent + */ + +import type { Context } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Fixture plugin name. */ +export const name = 'workspace-context-resume-agent' +/** Services that must exist before the fixture resumes its agent. */ +export const inject = ['agents', 'agentLoop', 'sessionPersistence'] + +/** + * Resume the seeded session and bind its handle to this fixture's lifetime. + * @param ctx - settled agent and persistence services from the Loader tree. + * @returns after the resumed agent is published. + */ +export async function apply(ctx: Context): Promise { + const handle = await ctx.agents.resume({ + resumeSessionId: 'workspace-context-resume' as SessionId, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) + ctx.effect(() => () => handle.dispose(), 'workspace-context-resume-agent.handle') +} diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.jsonl new file mode 100644 index 0000000000..84501283f2 --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"workspace-context-resume-replay","createdAt":1,"delegationDepth":0} diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.override.json b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.override.json new file mode 100644 index 0000000000..1a431e6dcc --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.override.json @@ -0,0 +1,11 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "RESUME_DONE" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "RESUME_DONE" } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl new file mode 100644 index 0000000000..9abcd67f44 --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl @@ -0,0 +1,20 @@ +{"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":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/end-seed","seq":4,"time":0,"data":{}} +{"type":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":9,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RESUME_DONE"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":18,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts new file mode 100644 index 0000000000..1d1c03b71e --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts @@ -0,0 +1,151 @@ +/** + * Assembled-app regression for persisted workspace-instruction resume state. + * @module workspace-context-resume-snapshot + */ + +import { createHash } from 'node:crypto' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + type SessionEvent, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { renderWorkspaceContext } from '@deepseek-ai/dsh-workspace-context' +import { describe, expect, it } from 'vitest' + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit') +const replayFixture = join(fixtureDir, 'replay.jsonl') +const replayOverride = join(fixtureDir, 'replay.override.json') +const sessionExpected = join(fixtureDir, 'session.expected.jsonl') +const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const sessionId = SessionId('workspace-context-resume') +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' +const oldInstruction = 'Old workspace instruction.' +const newInstruction = 'New workspace instruction after offline edit.' + +async function seedVisibleBaseline(root: string, cwd: string): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const meta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1, + cwd, + delegationDepth: 0, + } + const baseline = renderWorkspaceContext([{ + absolutePath: join(cwd, 'AGENTS.md'), + displayPath: 'AGENTS.md', + content: oldInstruction, + }], { maxBytes: 65536 }) + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 11, + data: createUserMessage({ content: [{ type: 'text', text: 'Remember the workspace instruction.' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { + type: 'user/message', + seq: 2, + time: 12, + data: createUserMessage({ + content: [{ type: 'text', text: baseline.text }], + source: { + kind: 'workspace-instructions', + baseline: true, + changes: [{ + action: 'set', + scope: '.\0AGENTS.md', + path: 'AGENTS.md', + digest: createHash('sha1').update(oldInstruction).digest('hex'), + }], + }, + }), + surfaceOp: 'append', + }, + { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + try { + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(sessionId, events) + const location = ctx.sessionPersistence.locate(meta) + if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') + return location.path + } finally { + await ctx.fiber.dispose() + } +} + +describe('workspace-context resume snapshot', () => { + it('appends an offline replacement without duplicating the visible baseline', async () => { + let cwd = '' + let sessionPath = '' + const result = await runLoaderSmoke({ + label: 'workspace-context resume headless stream-json snapshot', + tempDirPrefix: 'dsh-workspace-context-resume-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', 'Acknowledge the current workspace instruction.'], + tsconfigPath, + env: { + DSH_SNAPSHOT_FILE: replayFixture, + DSH_SNAPSHOT_OVERRIDE: replayOverride, + }, + prepare: async (runCwd) => { + cwd = runCwd + await mkdir(join(runCwd, '.git'), { recursive: true }) + await writeFile(join(runCwd, 'AGENTS.md'), `${newInstruction}\n`) + sessionPath = await seedVisibleBaseline(join(runCwd, '.sessions'), runCwd) + }, + inspect: async () => { + const normalization: NormalizeContext = { sessionIds: [sessionId], cwd } + const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization)) + if (refreshing) await writeFile(sessionExpected, session) + expect(session).toBe(await readFile(sessionExpected, 'utf8')) + + const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as { + type?: string + data?: { + source?: { kind?: string; baseline?: boolean; changes?: Array> } + content?: Array<{ type?: string; text?: string }> + } + }) + const workspaceEvents = records.filter(record => record.type === 'user/message' + && record.data?.source?.kind === 'workspace-instructions') + expect(workspaceEvents.filter(record => record.data?.source?.baseline === true)).toHaveLength(1) + expect(workspaceEvents.filter(record => record.data?.source?.baseline !== true)).toHaveLength(1) + expect(workspaceEvents.at(-1)?.data?.source?.changes).toMatchObject([{ + action: 'replace', scope: '.\0AGENTS.md', path: 'AGENTS.md', + }]) + expect(JSON.stringify(workspaceEvents.at(-1)?.data?.content)).toContain(newInstruction) + + const files = await readdir(join(cwd, '.sessions'), { recursive: true }) + expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(1) + }, + }) + + expect(result.stderr).toBe('') + const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) + expect(records.at(-1)).toMatchObject({ + type: 'result', + success: true, + sessionId, + result: 'RESUME_DONE', + reason: { kind: 'completed' }, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/headless-agent/workspace-context-resume.cordis.snapshot.yml b/examples/headless-agent/workspace-context-resume.cordis.snapshot.yml new file mode 100644 index 0000000000..f5c04d9093 --- /dev/null +++ b/examples/headless-agent/workspace-context-resume.cordis.snapshot.yml @@ -0,0 +1,37 @@ +# Keyless real-Loader composition for workspace-instruction resume +# reconciliation. The test seeds one persisted baseline, changes AGENTS.md +# while the session is offline, then resumes through the public agent service. + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: none + +- id: replay + name: '@deepseek-ai/dsh-llm-replay' + config: + file: !!js process.env.DSH_SNAPSHOT_FILE + overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: agent + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + agents: [] + workspaceContext: + maxBytes: 65536 + dshHome: !!js process.cwd() + '/.dsh' + skills: + enabled: false + toolBash: false + toolTasks: false + goals: false + +# Await the persisted resume before the headless driver inspects root agents. +- id: resumed-agent + name: './tests/fixtures/workspace-context-resume-agent.ts' diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 00f13a4d0b..478bf7e052 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/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/context/workspace-context/README.md -README.md: c4a3fae337cca70c98026bbdce7b252ba33bac0c -README.zh.md: 35b7fe6bfc7d247ff155d19053e3d9e4551838ae +README.md: 79f913a1cbabbcf474c5befccf01fb6eae76e843 +README.zh.md: 02f88c4e339b6a816b49db1eb9f91a440df787df diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index c4a3fae337..79f913a1cb 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -The baseline is injected at the first `agent/step` of each live session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. If a later surface replacement such as compaction shadows that baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session. +A complete baseline is injected at the first `agent/step` of a fresh session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. A resumed loop retains that baseline while it remains visible and appends only current-file transitions. If a later surface replacement such as compaction shadows the baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. @@ -48,11 +48,11 @@ The plugin owns the complete `` framing, and every injected `us ## State And Refresh -Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. -The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. After a surface replacement removes it, model-request prompt assembly recomposes the current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. A successful filesystem touch can still append later replacements or removals. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, or when a resumed loop prepares its baseline. +The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. A resumed loop or hot plugin remount retains that one visible baseline and reconciles its baseline and dynamic scopes against current files before the first request: unchanged files append nothing, while offline additions, edits, and removals append typed `set`, `replace`, and `remove` transitions. If no typed baseline remains visible, as after a surface replacement, model-request prompt assembly recomposes the complete current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. The in-memory scope marker and provider-version cache only select and accelerate probes. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, or when a resumed loop prepares its baseline. ## Configuration @@ -83,7 +83,7 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even #### What the model sees -At the first request of each loop instance, and again on the first request after a surface replacement shadows it, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order. +A fresh session's first request contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. A resumed request retains that message while it remains visible and adds only detected transitions; the first request after a surface replacement shadows it receives one recomposed complete baseline. ##### Baseline instruction template @@ -107,7 +107,7 @@ The rendered baseline remains in derived history until a surface replacement sha #### KV Cache effect -Append-only after the existing reusable prefix. A new, resumed, or post-compaction request may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position. +Append-only after the existing reusable prefix. A fresh or post-compaction request may append a complete baseline; a resumed request retains its visible baseline and appends only detected transitions, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position. ### Newly discovered scope context diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index 35b7fe6bfc..02f88c4e33 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -6,7 +6,7 @@ ## 生命周期 -基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。 +完整基线会在全新会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。恢复的 loop 会在该基线仍可见时保留它,只追加根据当前文件检测到的转换。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。 该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 @@ -48,11 +48,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when ## 状态与刷新 -模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 +模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。表层替换将其移除后,面向模型请求的提示词组装会重新组合当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。成功的文件系统 touch 仍可在之后追加替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。恢复的 loop 或插件热重挂会保留这一条可见基线,并在第一个请求前根据当前文件对账其基线和动态 scope:未变文件不追加任何内容,而 agent 离线期间新增、编辑或移除的文件会追加带类型的 `set`、`replace` 或 `remove` 转换。如果已无带类型的基线可见(例如表层替换后),面向模型请求的提示词组装会重新组合完整的当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 ## 配置 @@ -83,7 +83,7 @@ export interface Config { #### 模型看到的内容 -在每个 loop 实例的第一个请求中,以及表层替换将其遮蔽后的第一个请求中,模型都会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。 +全新会话的第一个请求包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。恢复后的请求会在该消息仍可见时保留它,并只追加检测到的转换;表层替换将其遮蔽后的第一个请求会收到一条重新组合的完整基线。 ##### 基线指令模板 @@ -107,7 +107,7 @@ Instructions from: AGENTS.md #### KV Cache 影响 -仅追加,位于现有可复用前缀之后。新建实例的请求、恢复后的请求或压缩后的请求可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。 +仅追加,位于现有可复用前缀之后。全新请求或压缩后的请求可能追加完整基线;恢复后的请求保留其可见基线,只追加检测到的转换,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。 ### 新发现的 scope 上下文 diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 7ca9189a4f..ad1cade619 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -72,22 +72,12 @@ export function apply(ctx: Context, config: Config): void { // interval before an injected baseline becomes a durable surface event. const baselineSettledGeneration = new WeakMap() const baselineQueuedGeneration = new WeakMap() - // Sessions whose lifecycle start this mount witnessed. A startup or resume - // emits agent/session-start before the first step; a hot remount attaches to - // an already-live session and never sees it. Resumes always re-compose the - // baseline from current files. Hot remounts retain a baseline only while its - // typed event remains model-visible. - const lifecycleWitnessed = new WeakSet() const pendingByParent = new Map() - ctx.on('agent/session-start', (agent: Agent) => { - lifecycleWitnessed.add(agent.session) - }) - ctx.on('session/event', (session, event) => { observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions) if (event.type === 'user/message' @@ -136,7 +126,7 @@ export function apply(ctx: Context, config: Config): void { pendingNestedChanges, instructionVersions, fileSystem, - { includeBaselineScopes: false, ...signal === undefined ? {} : { signal } }, + { includeBaselineScopes: keepVisibleBaseline, ...signal === undefined ? {} : { signal } }, ) signal?.throwIfAborted() const generation = agent.session.surface.replaceGeneration @@ -175,7 +165,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { if (baselineLoaded.has(agent.session)) return - const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent.session) + const keepVisibleBaseline = hasVisibleBaseline(agent.session) await prepareBaseline(agent, signal, keepVisibleBaseline) }) diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 7bb24a43b2..383c765719 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -39,7 +39,7 @@ const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) /** Durable provenance and reconciliation facts for one workspace context. */ export interface WorkspaceInstructionSource { kind: 'workspace-instructions' - /** Marks the complete startup/resume baseline rather than a later delta. */ + /** Marks a complete baseline rather than a later delta. */ baseline?: true changes: WorkspaceInstructionChange[] } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 4d46e1393e..ed57a16497 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1035,6 +1035,34 @@ describe('workspace context request injection', () => { } }) + it('retains one visible baseline across repeated session resumes', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await composeBaselinePrefix(ctx, original) + + const firstResume = stubAgent(root, [...original.session.events]) + agentEvents(ctx, firstResume).emit('agent/session-start', 'resume') + await composeBaselinePrefix(ctx, firstResume) + const secondResume = stubAgent(root, [...firstResume.session.events]) + agentEvents(ctx, secondResume).emit('agent/session-start', 'resume') + await composeBaselinePrefix(ctx, secondResume) + + expect(baselineEvents(firstResume)).toHaveLength(1) + expect(baselineEvents(secondResume)).toHaveLength(1) + expect(secondResume.session.events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions')).toHaveLength(1) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('retains a visible baseline after a plugin remount', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1307,7 +1335,7 @@ describe('workspace context request injection', () => { } }) - it('recomposes the baseline from current files when a resumed session edited it offline', async () => { + it('appends a replacement without duplicating the baseline when a resumed session edited it offline', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1318,28 +1346,70 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await composeBaselinePrefix(ctx, original) - // Offline edit to the baseline file, then resume on a fresh session whose - // seeded log already carries the original baseline. A resumed session is - // registered after this mount's apply(), so the remount guard never seeds - // it: its first step re-composes a fresh baseline from current files, - // reflecting the offline edit before the first resumed request. The old - // baseline stays in history unmutated (note: resume without mutating an - // earlier history event). await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') const resumed = stubAgent(root, [...original.session.events]) - // Resume announces its lifecycle start before the first step. agentEvents(ctx, resumed).emit('agent/session-start', 'resume') await composeBaselinePrefix(ctx, resumed) const baselines = baselineEvents(resumed) - expect(baselines).toHaveLength(2) - const latest = baselines.at(-1) - expect(latest?.type === 'user/message' && blocksText(latest.data.content)) - .toContain('new root rule after offline edit') - const original0 = baselines[0] - expect(original0?.type === 'user/message' && blocksText(original0.data.content)) + expect(baselines).toHaveLength(1) + expect(baselines[0]?.type === 'user/message' && blocksText(baselines[0].data.content)) .toContain('old root rule') + const update = resumed.session.events.findLast(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline !== true) + expect(update?.type === 'user/message' && update.data.source).toMatchObject({ + changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], + }) + expect(update?.type === 'user/message' && blocksText(update.data.content)) + .toContain('new root rule after offline edit') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it.each([ + { + name: 'adds a newly applicable baseline file', + action: 'set', + prepare: (root: string): Promise => write(join(root, 'pkg/AGENTS.md'), 'new package rule'), + scope: sk('pkg', 'AGENTS.md'), + path: join('pkg', 'AGENTS.md'), + }, + { + name: 'removes a deleted baseline file', + action: 'remove', + prepare: (root: string): Promise => rm(join(root, 'AGENTS.md')), + scope: sk('.', 'AGENTS.md'), + path: 'AGENTS.md', + }, + ])('$name during resume without duplicating the visible baseline', async ({ action, prepare, scope, path }) => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'pkg') + await mkdir(join(root, '.git'), { recursive: true }) + await mkdir(cwd, { recursive: true }) + await write(join(root, 'AGENTS.md'), 'root rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(cwd) + await composeBaselinePrefix(ctx, original) + + await prepare(root) + const resumed = stubAgent(cwd, [...original.session.events]) + agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + await composeBaselinePrefix(ctx, resumed) + + expect(baselineEvents(resumed)).toHaveLength(1) + const update = resumed.session.events.findLast(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline !== true) + expect(update?.type === 'user/message' && update.data.source).toMatchObject({ + changes: [{ action, scope, path }], + }) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) From f96acad43829e6e7461418fa738265cdd8398396 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 19:55:39 +0800 Subject: [PATCH 015/179] fix(subagent): preserve Codex fatal and grace semantics --- packages/subagent/subagent-codex/src/run.ts | 50 ++++++++++++++- packages/subagent/subagent-codex/src/wire.ts | 26 ++++---- .../tests/subagent-codex.spec.ts | 48 +++++++++++++- .../subprocess/subprocess-local/src/spawn.ts | 63 +++++++++++++++++-- .../subprocess-local/tests/spawn.spec.ts | 43 ++++++++++++- 5 files changed, 210 insertions(+), 20 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 21f22d8a1b..811f7c8f98 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -24,6 +24,47 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +/** + * Bound final exit observation at twice a positive finite grace without + * narrowing the public config to Node's single-timer integer range. + */ +function doubledGraceWindow(graceMs: number): { + readonly signal: AbortSignal + readonly cancel: () => void +} { + const whole = Math.floor(graceMs) + let remaining = BigInt(whole) * 2n + + BigInt(Math.ceil((graceMs - whole) * 2)) + const controller = new AbortController() + let timer: ReturnType | undefined + const arm = (): void => { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + timer = setTimeout(() => { + timer = undefined + if (remaining === 0n) { + controller.abort() + } else { + arm() + } + }, Number(chunk)) + } + arm() + return { + signal: controller.signal, + cancel: () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + }, + } +} + /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ @@ -88,8 +129,13 @@ export async function disposeCodexChild( // A concurrently closed stdin does not change tree ownership below. } child.terminate() - if (!(await child.waitForExit(AbortSignal.timeout(graceMs * 2)))) { - throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') + const exitWindow = doubledGraceWindow(graceMs) + try { + if (!(await child.waitForExit(exitWindow.signal))) { + throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') + } + } finally { + exitWindow.cancel() } await child.done } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 4e920113e3..304c5eadb4 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -17,12 +17,17 @@ type JsonObject = Record interface Deferred { readonly promise: Promise readonly resolve: (value: T) => void + readonly reject: (reason?: unknown) => void } function deferred(): Deferred { let resolve!: (value: T) => void - const promise = new Promise((settle) => { resolve = settle }) - return { promise, resolve } + let reject!: (reason?: unknown) => void + const promise = new Promise((settle, fail) => { + resolve = settle + reject = fail + }) + return { promise, resolve, reject } } function object(value: unknown, label: string): JsonObject { @@ -93,7 +98,7 @@ async function raceAbort(pending: Promise, signal: AbortSignal): Promise() + private readonly fatal = deferred() private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined @@ -111,6 +116,10 @@ export class CodexAppServerWire { output: Writable, ) { this.transport = new JsonRpcLineTransport(input, output) + // Fatal protocol state can arrive after the current guarded operation has + // already settled. Keep the shared rejection observed without inserting + // another promise-adoption hop into active races. + void this.fatal.promise.catch(() => {}) this.transport.onRequest((method, params) => this.handleServerRequest(method, params)) this.transport.onNotification((method, params) => { try { @@ -157,9 +166,8 @@ export class CodexAppServerWire { * Create the run's private ephemeral thread and retain its identity. * @param cwd - parent Session workspace. * @param signal - unpublished-start cancellation. - * @returns the app-server thread id. */ - async startThread(cwd: string, signal: AbortSignal): Promise { + async startThread(cwd: string, signal: AbortSignal): Promise { const response = object(await this.guarded(this.transport.request('thread/start', { cwd, ephemeral: true, @@ -170,7 +178,6 @@ export class CodexAppServerWire { throw new Error('subagent-codex: app-server did not create an ephemeral thread') } this.threadId = id - return id } /** @@ -249,15 +256,12 @@ export class CodexAppServerWire { } private async guarded(pending: Promise, signal: AbortSignal): Promise { - const withFatal = Promise.race([ - pending, - this.fatal.promise.then((error): Promise => Promise.reject(error)), - ]) + const withFatal = Promise.race([pending, this.fatal.promise]) return raceAbort(withFatal, signal) } private fail(error: Error): void { - this.fatal.resolve(error) + this.fatal.reject(error) } private readonly onInputError = (error: Error): void => { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 6cc4461e06..18e28cc7cc 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -210,7 +210,7 @@ async function initializeWire(): Promise<{ const starting = wire.startThread(process.cwd(), new AbortController().signal) const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) - await expect(starting).resolves.toBe('thread-1') + await starting return { child, wire } } @@ -516,6 +516,21 @@ describe('CodexAppServerWire', () => { } }) + it('keeps an earlier fatal frame authoritative over later completion in the same chunk', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send( + agentMessage('invalid', 'future_phase'), + agentMessage('late answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).rejects.toThrow('unknown agent message phase') + wire.close() + }) + it('gives local cancellation precedence over a remote completed turn', async () => { const { child, wire } = await initializeWire() let cancelled = false @@ -1038,6 +1053,37 @@ describe('disposeCodexChild', () => { expect(child.waitForExit).toHaveBeenCalledTimes(1) }) + it('accepts fractional and larger-than-Node grace windows', async () => { + for (const graceMs of [0.25, Number.MAX_VALUE]) { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle, graceMs)) + .resolves.toBeUndefined() + const signal = vi.mocked(child.waitForExit).mock.calls[0]?.[0] + expect(signal?.aborted).toBe(false) + } + }) + + it('chains a doubled grace window beyond one Node timer segment', async () => { + vi.useFakeTimers() + try { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const disposal = disposeCodexChild( + wire, + child.handle, + 1_073_741_823.75, + ) + const rejected = expect(disposal) + .rejects.toThrow('did not exit within its dispose window') + await vi.advanceTimersByTimeAsync(2_147_483_647) + await vi.advanceTimersByTimeAsync(1) + await rejected + } finally { + vi.useRealTimers() + } + }) + it('contains a concurrently closed stdin error', async () => { const child = fakeChild() const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 90d460c2c5..d3cbb0cf55 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -55,6 +55,47 @@ function sleepTick(): Promise { return sleepMs(15) } +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +/** + * Schedule a positive finite millisecond delay across as many Node-safe timer + * segments as necessary. Fractional milliseconds round up so a grace never + * expires earlier than configured. + * @param delayMs - positive finite delay in milliseconds. + * @param callback - work to run after the complete delay. + * @returns a handle that cancels the active segment and all future segments. + */ +export function scheduleFiniteTimeout( + delayMs: number, + callback: () => void, +): { cancel(): void } { + let remaining = BigInt(Math.ceil(delayMs)) + let timer: ReturnType | undefined + const arm = (): void => { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + timer = setTimeout(() => { + timer = undefined + if (remaining === 0n) { + callback() + } else { + arm() + } + }, Number(chunk)) + } + arm() + return { + cancel(): void { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + }, + } +} + let spillCounter = 0 let defaultSpillDir: string | undefined @@ -341,7 +382,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') const stderrCollector = collectStream(errMode, child.stderr, 'stderr') - let graceTimer: NodeJS.Timeout | undefined + let graceTimer: ReturnType | undefined let settled = false // Failed spawns use pid -1 so signalling remains a no-op. @@ -377,6 +418,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // child and must stay signalable, while a fully-dead tree (possible pid // reuse) must not be re-signalled by a later tier. const kill = (sig: NodeJS.Signals): void => { + /* v8 ignore next -- the exit monitor cancels the ordinary dead-tree timer; + this remains the timer/death race guard and cannot be staged deterministically. */ if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } @@ -390,7 +433,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // kill() re-probes tree liveness before force-killing. It stays ref'd: // the pending SIGKILL is a commitment, and a parent exiting before it // fires would orphan a trapped survivor. Self-bounds at graceMs. - graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) + const timer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) + graceTimer = timer + // A very large configured grace must not pin the parent after TERM already + // removed the whole tree. Keep the escalation armed only while its target + // remains alive; direct-child settlement alone is not sufficient. + void waitForExit().then(() => { + timer.cancel() + graceTimer = undefined + }) } // The caller owns timeout classification; this layer only reacts to abort. @@ -405,7 +456,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const done = new Promise((resolve, reject) => { - let pipeDrainTimer: NodeJS.Timeout | undefined + let pipeDrainTimer: ReturnType | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return settled = true @@ -428,13 +479,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // A surviving descendant that inherited a pipe must not hold the // outcome open indefinitely: after exit, the same bounded grace that // governs kills also bounds the close wait. - pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) + pipeDrainTimer = scheduleFiniteTimeout(spec.graceMs, () => { + settle(exitCode, signal) + }) }) child.on('close', settle) function cleanup(): void { // graceTimer deliberately NOT cleared: the SIGKILL escalation must be // able to reach tree survivors after the direct child settles. - if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) + pipeDrainTimer?.cancel() spec.signal?.removeEventListener('abort', onAbort) } }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 491756f01f..87c81116ff 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -2,7 +2,13 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts' +import { + killGroup, + OutputCollector, + scheduleFiniteTimeout, + spawnSubprocess, + taskkillProcessTree, +} from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ @@ -101,6 +107,30 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } +describe('scheduleFiniteTimeout', () => { + it('rounds fractions up, chains Node-safe segments, and cancels idempotently', async () => { + vi.useFakeTimers() + try { + const fired = vi.fn() + const chained = scheduleFiniteTimeout(2_147_483_647.25, fired) + await vi.advanceTimersByTimeAsync(2_147_483_647) + expect(fired).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(fired).toHaveBeenCalledOnce() + chained.cancel() + + const cancelled = vi.fn() + const timer = scheduleFiniteTimeout(0.25, cancelled) + timer.cancel() + timer.cancel() + await vi.advanceTimersByTimeAsync(1) + expect(cancelled).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) + describe('spawnSubprocess', () => { it('captures stdout on success', async () => { const result = await finish(spawnSubprocess(spec('echo hello'))) @@ -164,6 +194,17 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) + it('cancels a larger-than-Node escalation timer once SIGTERM removes the tree', async () => { + const running = spawnSubprocess(spec('echo ready; sleep 60', { + graceMs: Number.MAX_VALUE, + })) + await waitForStdout(running, 'ready\n') + running.terminate() + const result = await running.done + expect(result.signal).toBe('SIGTERM') + await expect(running.waitForExit()).resolves.toBe(true) + }) + it('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. From 3da48d174168cb48bf1630fe1c61fc31d67087e0 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 20:01:04 +0800 Subject: [PATCH 016/179] fix(ci): register workspace resume fixture --- knip.json | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.json b/knip.json index 4bb84659f2..90ce89880e 100644 --- a/knip.json +++ b/knip.json @@ -35,6 +35,7 @@ "headless-agent/tests/fixtures/cli-mock-llm.ts", "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", + "headless-agent/tests/fixtures/workspace-context-resume-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", From 01fa4ceb6e44818b6146991332cef6853218a319 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 20:12:32 +0800 Subject: [PATCH 017/179] test(workspace-context): cover pre-resume tool reconciliation --- .../tests/workspace-context.spec.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index ed57a16497..ed8f36c11c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1063,6 +1063,35 @@ describe('workspace context request injection', () => { } }) + it('ignores a restored baseline during a file tool call before resume reconciliation', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + await write(join(root, 'file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await composeBaselinePrefix(ctx, original) + + const resumed = stubAgent(root, [...original.session.events]) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-before-resume-reconciliation'), + name: 'read', + arguments: { file_path: 'file.txt' }, + agent: resumed, + }) + + expect(workspaceContextOf(result)).toBeUndefined() + expect(baselineEvents(resumed)).toHaveLength(1) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('retains a visible baseline after a plugin remount', async () => { const root = await tempRepo() const home = await tempRepo() From 451c2929ed3b94804da804817823aafc6f8aafcd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 20:26:45 +0800 Subject: [PATCH 018/179] fix(subagent): close terminal teardown races --- packages/subagent/subagent-acp/src/run.ts | 44 +++++++++--- .../subagent-acp/tests/subagent-acp.spec.ts | 68 ++++++++++++++++++- packages/subagent/subagent-codex/src/wire.ts | 2 +- .../tests/subagent-codex.spec.ts | 14 ++-- .../subprocess/subprocess-local/src/spawn.ts | 21 +++--- .../subprocess-local/tests/spawn.spec.ts | 16 ++++- 6 files changed, 135 insertions(+), 30 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index fba0403739..f264261b0e 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -90,15 +90,41 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ -async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { - const controller = new AbortController() - const timer = setTimeout(() => { controller.abort() }, ms) - try { - return await child.waitForExit(controller.signal) - } finally { - clearTimeout(timer) +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +function scaledFiniteMilliseconds(ms: number, scale: number): bigint { + const whole = Math.floor(ms) + return BigInt(whole) * BigInt(scale) + + BigInt(Math.ceil((ms - whole) * scale)) +} + +/** + * Bounded whole-tree exit wait across Node-safe timer segments. + * @param child - process tree whose liveness is authoritative. + * @param ms - positive finite base window in milliseconds. + * @param scale - integer multiplier applied without Number overflow. + */ +async function treeExitsWithin( + child: SubprocessHandle, + ms: number, + scale = 1, +): Promise { + let remaining = scaledFiniteMilliseconds(ms, scale) + while (remaining > 0n) { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, Number(chunk)) + try { + if (await child.waitForExit(controller.signal)) return true + } finally { + clearTimeout(timer) + } } + return false } /** @@ -125,7 +151,7 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe // (this plugin passes disposeGraceMs there), so the bound covers both the // escalation window and an equal confirmation window after the SIGKILL. child.terminate() - if (!(await treeExitsWithin(child, graceMs * 2))) { + if (!(await treeExitsWithin(child, graceMs, 2))) { throw new Error('ACP child process tree did not exit within its dispose windows') } } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index f2cbeda27b..1a8bc577c1 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' @@ -190,6 +190,72 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) }) + it('keeps an oversized finite escalation window instead of collapsing it to one millisecond', async () => { + vi.useFakeTimers() + try { + let waitCount = 0 + let reportExited!: (exited: boolean) => void + const terminate = vi.fn() + const waitForExit = vi.fn((signal?: AbortSignal) => { + waitCount += 1 + return new Promise((resolve) => { + signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) + if (waitCount === 2) reportExited = resolve + }) + }) + const child: Parameters[0] = { + pid: 1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + terminate, + waitForExit, + } + const disposal = disposeAcpChild(child, 0.25, Number.MAX_VALUE) + await vi.advanceTimersByTimeAsync(1) + expect(terminate).toHaveBeenCalledOnce() + expect(waitForExit).toHaveBeenCalledTimes(2) + const escalationSignal = waitForExit.mock.calls[1]?.[0] + await vi.advanceTimersByTimeAsync(1) + expect(escalationSignal?.aborted).toBe(false) + reportExited(true) + await expect(disposal).resolves.toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('chains a doubled grace beyond one Node timer segment', async () => { + vi.useFakeTimers() + try { + const waitForExit = vi.fn((signal?: AbortSignal) => new Promise((resolve) => { + signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) + })) + const child: Parameters[0] = { + pid: 1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + terminate: vi.fn(), + waitForExit, + } + const disposal = disposeAcpChild(child, 0.25, 1_073_741_823.75) + const rejected = expect(disposal).rejects.toThrow(/did not exit within its dispose windows/) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(2_147_483_647) + expect(waitForExit).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1) + await rejected + } finally { + vi.useRealTimers() + } + }) + it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ argv: ['bash', '-c', 'true'], diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 304c5eadb4..f933c1a04b 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -256,7 +256,7 @@ export class CodexAppServerWire { } private async guarded(pending: Promise, signal: AbortSignal): Promise { - const withFatal = Promise.race([pending, this.fatal.promise]) + const withFatal = Promise.race([this.fatal.promise, pending]) return raceAbort(withFatal, signal) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 18e28cc7cc..8e6c7ebd51 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -369,8 +369,9 @@ describe('CodexAppServerWire', () => { { type: 'text', text: 'second', text_elements: [] }, ], }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() child.peer.send( - { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, { method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, @@ -516,18 +517,17 @@ describe('CodexAppServerWire', () => { } }) - it('keeps an earlier fatal frame authoritative over later completion in the same chunk', async () => { + it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal, () => false) const turnStart = await child.peer.nextMethod('turn/start') - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - await nextTask() child.peer.send( - agentMessage('invalid', 'future_phase'), - agentMessage('late answer', 'final_answer'), + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + { id: 'future-request', method: 'future/request', params: {} }, + agentMessage('early answer', 'final_answer'), turnCompleted('completed'), ) - await expect(result).rejects.toThrow('unknown agent message phase') + await expect(result).rejects.toThrow('unsupported app-server request') wire.close() }) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index d3cbb0cf55..37e36bd213 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -383,6 +383,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: ReturnType | undefined + let terminationStarted = false let settled = false // Failed spawns use pid -1 so signalling remains a no-op. @@ -418,14 +419,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // child and must stay signalable, while a fully-dead tree (possible pid // reuse) must not be re-signalled by a later tier. const kill = (sig: NodeJS.Signals): void => { - /* v8 ignore next -- the exit monitor cancels the ordinary dead-tree timer; + /* v8 ignore next -- a successful consumer wait cancels the ordinary dead-tree timer; this remains the timer/death race guard and cannot be staged deterministically. */ if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } const terminate = (): void => { - if (graceTimer !== undefined) return // escalation already in flight + if (terminationStarted) return + terminationStarted = true if (!treeAlive()) return kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying @@ -433,15 +435,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // kill() re-probes tree liveness before force-killing. It stays ref'd: // the pending SIGKILL is a commitment, and a parent exiting before it // fires would orphan a trapped survivor. Self-bounds at graceMs. - const timer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) - graceTimer = timer - // A very large configured grace must not pin the parent after TERM already - // removed the whole tree. Keep the escalation armed only while its target - // remains alive; direct-child settlement alone is not sufficient. - void waitForExit().then(() => { - timer.cancel() - graceTimer = undefined - }) + graceTimer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) } // The caller owns timeout classification; this layer only reacts to abort. @@ -497,6 +491,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter if (signal?.aborted) return false await sleepTick() } + // Successful observation is the permanent no-more-signals boundary. It + // also cancels an escalation whose TERM tier already removed the tree. + terminationStarted = true + graceTimer?.cancel() + graceTimer = undefined return true } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 87c81116ff..f08e18c2ed 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -668,7 +668,6 @@ describe('coverage seams', () => { it('terminate() after the tree died delivers no termination signal', async () => { const running = spawnSubprocess(spec('true')) await running.done - await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { running.terminate() @@ -677,6 +676,21 @@ describe('coverage seams', () => { } finally { spy.mockRestore() } + await running.waitForExit() + }) + + it('repeated terminate after exit never probes or signals a reused process group', async () => { + const running = spawnSubprocess(spec('sleep 60')) + running.terminate() + await running.done + await running.waitForExit() + const spy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + running.terminate() + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } }) it('waitForExit on a failed spawn reports exited immediately', async () => { From 8067701b92982877005fa0c6f567f7aa6caf9300 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 20:45:12 +0800 Subject: [PATCH 019/179] fix(subprocess): stop escalation after tree exit --- .../subprocess/subprocess-local/src/spawn.ts | 53 +++++++++++++++---- .../subprocess-local/tests/spawn.spec.ts | 48 +++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 37e36bd213..a5d7f52d0c 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -384,6 +384,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter let graceTimer: ReturnType | undefined let terminationStarted = false + let treeExitObserved = false + let treeExitObservation: Promise | undefined let settled = false // Failed spawns use pid -1 so signalling remains a no-op. @@ -391,6 +393,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter /** Whether the detached tree's root (or POSIX group) is still alive. */ const treeAlive = (): boolean => { + /* v8 ignore next -- only a timer callback already queued when the observer settles can enter here; + the guard is the final defense against probing an id after its tree was confirmed absent. */ + if (treeExitObserved) return false if (pid <= 0) return false if (platform === 'win32') { // Windows has no group-liveness probe; the direct child's exit is the @@ -413,13 +418,29 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } } + /** + * Start or reuse the handle's single whole-tree exit observer. The first + * confirmed absence is a permanent no-more-signals boundary: it cancels a + * pending escalation before this process-group id can be reused. + */ + const observeTreeExit = (): Promise => { + treeExitObservation ??= (async () => { + while (treeAlive()) await sleepTick() + treeExitObserved = true + terminationStarted = true + graceTimer?.cancel() + graceTimer = undefined + })() + return treeExitObservation + } + // The escalation's tier primitive (not on the handle — terminate() is the // only consumer-facing termination verb). Guards on TREE liveness, not // outcome settlement: a TERM-trapping helper can outlive the settled direct // child and must stay signalable, while a fully-dead tree (possible pid // reuse) must not be re-signalled by a later tier. const kill = (sig: NodeJS.Signals): void => { - /* v8 ignore next -- a successful consumer wait cancels the ordinary dead-tree timer; + /* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer; this remains the timer/death race guard and cannot be staged deterministically. */ if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) @@ -428,7 +449,10 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const terminate = (): void => { if (terminationStarted) return terminationStarted = true - if (!treeAlive()) return + // Observe from the first termination tier onward, even when inherited + // pipes delay `done` and no consumer has begun its own teardown wait. + void observeTreeExit() + if (treeExitObserved) return kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying // does not mean the tree died — so settle does not clear this timer, and @@ -487,16 +511,23 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }) const waitForExit = async (signal?: AbortSignal): Promise => { - while (treeAlive()) { - if (signal?.aborted) return false - await sleepTick() + const observed = observeTreeExit() + if (treeExitObserved) return true + if (signal?.aborted) return false + if (signal === undefined) { + await observed + return true + } + const aborted = Promise.withResolvers() + const onAbort = (): void => { aborted.resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + /* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */ + if (signal.aborted) onAbort() + try { + return await Promise.race([observed.then(() => true), aborted.promise]) + } finally { + signal.removeEventListener('abort', onAbort) } - // Successful observation is the permanent no-more-signals boundary. It - // also cancels an escalation whose TERM tier already removed the tree. - terminationStarted = true - graceTimer?.cancel() - graceTimer = undefined - return true } return { diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index f08e18c2ed..ad2fc0f30a 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -205,6 +205,54 @@ describe('spawnSubprocess', () => { await expect(running.waitForExit()).resolves.toBe(true) }) + it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => { + const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`) + const graceMs = 160 + const childScript = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: ['ignore', 1, 2], + }) + writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid)) + helper.unref() + setInterval(() => {}, 1000) + ` + const running = spawnSubprocess({ + ...spec('unused', { graceMs }), + argv: [process.execPath, '-e', childScript], + }) + const helper = await waitForPidFile(pidFile) + const realKill: typeof process.kill = process.kill.bind(process) + let termAt = 0 + let forceSignals = 0 + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (target !== -running.pid) return realKill(target, signal) + if (signal === 'SIGTERM') { + termAt = Date.now() + return realKill(target, signal) + } + if (signal === 'SIGKILL') { + forceSignals += 1 + return true + } + if (signal === 0 && termAt !== 0 && Date.now() - termAt < graceMs / 2) { + throw Object.assign(new Error('simulated vanished process group'), { code: 'ESRCH' }) + } + return true // Before TERM the original group is live; later its pgid is reused. + }) + try { + running.terminate() + await running.done + expect(forceSignals).toBe(0) + } finally { + killSpy.mockRestore() + process.kill(helper, 'SIGKILL') + await waitGone(helper) + } + }) + it('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. From 74f2ab90e1235b739938f5f36a16d5788c03a9d0 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 20:55:29 +0800 Subject: [PATCH 020/179] refactor(subprocess): derive termination state --- packages/subprocess/subprocess-local/src/spawn.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index a5d7f52d0c..932daa2c59 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -383,7 +383,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: ReturnType | undefined - let terminationStarted = false let treeExitObserved = false let treeExitObservation: Promise | undefined let settled = false @@ -427,7 +426,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter treeExitObservation ??= (async () => { while (treeAlive()) await sleepTick() treeExitObserved = true - terminationStarted = true graceTimer?.cancel() graceTimer = undefined })() @@ -447,11 +445,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const terminate = (): void => { - if (terminationStarted) return - terminationStarted = true + if (treeExitObserved || graceTimer !== undefined) return // Observe from the first termination tier onward, even when inherited // pipes delay `done` and no consumer has begun its own teardown wait. void observeTreeExit() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await. if (treeExitObserved) return kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying From be56f64b7a7aef49ff83d7fea34283b096896658 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 20:57:55 +0800 Subject: [PATCH 021/179] feat(subagent): add Claude Code provider --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 2 +- .../2026-06-22-acp-subagent-backend.i18n.yaml | 4 +- .../2026-06-22-acp-subagent-backend.md | 2 +- .../2026-06-22-acp-subagent-backend.zh.md | 2 +- ...code-and-codex-subagent-backends.i18n.yaml | 6 + ...claude-code-and-codex-subagent-backends.md | 86 ++ ...ude-code-and-codex-subagent-backends.zh.md | 86 ++ ...30-generated-third-party-notices.i18n.yaml | 4 +- ...026-07-30-generated-third-party-notices.md | 10 +- ...-07-30-generated-third-party-notices.zh.md | 10 +- ...code-and-codex-subagent-backends.i18n.yaml | 6 - ...claude-code-and-codex-subagent-backends.md | 86 -- ...ude-code-and-codex-subagent-backends.zh.md | 86 -- THIRD_PARTY_NOTICES.md | 22 +- docs/capability-seams.md | 7 +- docs/config-catalog.md | 19 + docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../subagent/subagent-claude-code/cordis.yml | 40 + .../subagent/subagent-claude-code/driver.ts | 65 ++ .../subagent/subagent-claude-code/fixture.ts | 7 + examples/package.json | 1 + knip.json | 12 + packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 3 +- packages/subagent/README.zh.md | 3 +- .../subagent-claude-code/README.i18n.yaml | 6 + .../subagent/subagent-claude-code/README.md | 96 ++ .../subagent-claude-code/README.zh.md | 96 ++ .../subagent-claude-code/package.json | 53 ++ .../subagent-claude-code/src/index.ts | 95 ++ .../subagent-claude-code/src/invariant.ts | 31 + .../subagent-claude-code/src/process.ts | 159 ++++ .../subagent/subagent-claude-code/src/run.ts | 357 ++++++++ .../tests/loader-composition.e2e.ts | 72 ++ .../tests/messages-fixture.ts | 163 ++++ .../tests/real-product.spec.ts | 226 +++++ .../tests/subagent-claude-code.spec.ts | 845 ++++++++++++++++++ .../subagent-claude-code/tsconfig.json | 28 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 3 +- packages/subagent/subagent/README.zh.md | 3 +- pnpm-lock.yaml | 149 +++ scripts/gen-doc-graphs.ts | 6 +- scripts/gen-third-party-notices.spec.ts | 84 +- scripts/gen-third-party-notices.ts | 161 +++- tsconfig.host.json | 1 + vitest.config.ts | 1 + 54 files changed, 3014 insertions(+), 220 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml delete mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md delete mode 100644 .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts create mode 100644 examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts create mode 100644 packages/subagent/subagent-claude-code/README.i18n.yaml create mode 100644 packages/subagent/subagent-claude-code/README.md create mode 100644 packages/subagent/subagent-claude-code/README.zh.md create mode 100644 packages/subagent/subagent-claude-code/package.json create mode 100644 packages/subagent/subagent-claude-code/src/index.ts create mode 100644 packages/subagent/subagent-claude-code/src/invariant.ts create mode 100644 packages/subagent/subagent-claude-code/src/process.ts create mode 100644 packages/subagent/subagent-claude-code/src/run.ts create mode 100644 packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts create mode 100644 packages/subagent/subagent-claude-code/tests/messages-fixture.ts create mode 100644 packages/subagent/subagent-claude-code/tests/real-product.spec.ts create mode 100644 packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts create mode 100644 packages/subagent/subagent-claude-code/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 61640aade4..f8b82ebfbf 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: fd22b883572e5304c1587c818026c36235ef504d -2026-06-21-subagent-capability-seam.zh.md: fcfdf3e9c1eb35d7c372aa4311ebf8da192b56a7 +2026-06-21-subagent-capability-seam.md: 5b9b018df151f0d734b54cfdd4dacfd09058f7d7 +2026-06-21-subagent-capability-seam.zh.md: 49571288e35abb1369c16abd5c77a81dd3212a12 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index fd22b88357..5b9b018df1 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -14,7 +14,7 @@ The distinctive requirement — the one that shapes the whole design — is that - **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); -- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); +- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); - later: **A2A** and the **Claude Code Agent SDK** — the same out-of-process "start a child, prompt it, settle, cancel" shape; the Claude sibling remains in the product-provider proposal. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index fcfdf3e9c1..49571288e3 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -14,7 +14,7 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智 - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); -- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); +- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); - 后续:**A2A** 与 **Claude Code Agent SDK**——两者采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」;Claude 兄弟提供方仍在产品提供方提案中。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 207a8e7f6d..54ba7268df 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md -2026-06-22-acp-subagent-backend.md: ea0ec821b1f7a55d173f58c5bc4ba8829ef65c54 -2026-06-22-acp-subagent-backend.zh.md: 32e83a7a4e89eb7adb17220dc66952bca0a165aa +2026-06-22-acp-subagent-backend.md: d839ab6f75d8a518c9bc850894d1c3c5ffdbed92 +2026-06-22-acp-subagent-backend.zh.md: e9027e282bf351890643e0545b01fe00287375a6 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index ea0ec821b1..d839ab6f75 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -59,4 +59,4 @@ Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The par ## Future providers -The [Codex app-server provider](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols. +The [Codex app-server provider](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols. diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 32e83a7a4e..e9027e282b 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -59,4 +59,4 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ## 后续提供方 -[Codex app-server 提供方](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。 +[Codex app-server 提供方](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml new file mode 100644 index 0000000000..f90230b6a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +2026-08-04-claude-code-and-codex-subagent-backends.md: 923ac10573ee34746ff26d97b650edf4cddcaa9d +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c1368296f2f9c3887db74f16516fad6d0de8d620 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md new file mode 100644 index 0000000000..923ac10573 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -0,0 +1,86 @@ +# Agent Note: Claude Code and Codex subagent backends + +Status: implemented + +English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) + +## Problem + +The named [`ctx.subagents`](2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. Each route must hand the product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. + +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official integration, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot prove the former; a hand-mounted plugin cannot prove the latter. + +## Decision + +The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. + +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. + +```text +fixed tool → shared subagent service → product provider → official product process + ← final answer / explicit error / cancellation ← terminal product fact + → foreground disposal → shared process-tree termination → whole-tree exit +``` + +### Ownership and lifecycle + +| Phase | Shared owner | Product-specific responsibility | Observable result | +| --- | --- | --- | --- | +| Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published | +| Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects | +| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive an existing shared stop reason; Codex uses `max-tokens` only for explicit context exhaustion | The parent receives only a final answer or an explicit failure | +| Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits | + +## Codex provider + +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. + +Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. + +`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. + +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. + +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. + +## Claude Code provider + +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. + +The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. + +The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted`. + +Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable. + +## Distribution and evidence + +Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped user configuration, verifies both fixed foreground-only tools in one context, and starts neither product process. + +The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. + +The Claude Code evidence pins Agent SDK 0.3.220 and its platform-distributed Claude Code 2.1.220 CLI. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader e2e resolves both product packages by name while neither product command is available and records zero child starts. + +The project owner's distribution authorization is scoped to the official `@anthropic-ai/claude-agent-sdk` identity and the official Claude Code CLI/platform payloads each SDK version declares through `optionalDependencies`. [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) derives and discloses the current payload set without reclassifying its declared terms as permissive. Version, license-field, and payload-set changes still undergo ordinary dependency, lockfile, compatibility, terms, and notices review; unrelated non-permissive runtime packages continue to fail closed. + +## Alternatives considered + +**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. + +**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly. + +**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. + +**Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. + +**Plugin-managed login, product home, models, settings, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. + +**Continuation, progress, background collection, and shared parent context.** The delivered user result is one self-contained task and one final answer. Product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and background collection need separate user contracts and are not prebuilt. + +## Consequences + +Users can install either or both product providers, bind stable foreground tools in their own Cordis configuration, and delegate one self-contained task through the existing subagent contract. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. + +Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. + +Compatibility is pinned by package-level unit coverage, real-product loopback tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product baseline change must refresh those facts; production performs no separate runtime version probe. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md new file mode 100644 index 0000000000..c1368296f2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -0,0 +1,86 @@ +# Agent Note: Claude Code 与 Codex subagent 后端 + +Status: implemented + +[English](2026-08-04-claude-code-and-codex-subagent-backends.md) | 中文 + +## 问题 + +命名的 [`ctx.subagents`](2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。每条路径都必须向产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 + +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据要区分两个事实:真实产品测试证明官方集成、原生身份验证形态、最终回答与资源清理;Loader 组合测试证明公开包(package)和文档所示的工具配置无需启动产品即可加载。直接发起模型 HTTP 请求或使用产品替身无法证明前者;手工挂载插件无法证明后者。 + +## 决策 + +harness 将两个一次性兄弟提供方作为可独立安装、选择启用的包交付。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,`subagent_claude_code` 绑定 `claude-code`。随产品交付的 CLI(命令行界面)依赖闭包,以及基础、Web 与 headless 配置都不会加载任一提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 + +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 + +```text +fixed tool → shared subagent service → product provider → official product process + ← final answer / explicit error / cancellation ← terminal product fact + → foreground disposal → shared process-tree termination → whole-tree exit +``` + +### 归属与生命周期 + +| 阶段 | 共享责任方 | 产品特定职责 | 可观察结果 | +| --- | --- | --- | --- | +| 解析 | `dsh-tool-subagent` 与 `ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 | +| 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 | +| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导出一种现有的共享停止原因;Codex 仅在明确发生上下文耗尽时使用 `max-tokens` | 父级只会收到最终回答或明确失败 | +| dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 | + +## Codex 提供方 + +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 + +发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 + +`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 + +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 + +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 + +## Claude Code 提供方 + +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 + +公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 + +只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`。 + +启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。 + +## 分发与证据 + +每个产品都负责覆盖所有分支的包测试、一项必跑的真实产品测试和一项 Loader 组合 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示形态的用户配置,在同一个上下文中验证两个固定且只支持前台执行的工具,并且不会启动任何产品进程。 + +Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 + +Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。 + +项目所有者的分发授权范围限定为官方 `@anthropic-ai/claude-agent-sdk` 身份,以及每个 SDK 版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 会推导并披露当前载荷集合,但不会将其声明条款重新归类为宽松条款。版本、许可证字段和载荷集合发生变化时,仍须经过常规的依赖、锁文件、兼容性、条款和声明评审;无关的非宽松运行时包继续以默认拒绝方式失败。 + +## 曾考虑的替代方案 + +**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展集成接口,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都改用相应的官方产品集成。 + +**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 + +**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 + +**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 + +**由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 + +**续接、进度、后台收集和共享父级上下文。** 已交付的用户结果是一项自包含任务和一个最终回答。产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集都需要独立的用户契约,当前实现不会预先构建这些功能。 + +## 后果 + +用户可以安装任一或两个产品提供方,在自己的 Cordis 配置中绑定稳定的前台工具,并通过现有 subagent 契约委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 + +每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 + +兼容性由包级单元测试覆盖率、真实产品回环测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index d65dae2802..da2fdfd101 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043 -2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561 +2026-07-30-generated-third-party-notices.md: cbabd1142ad292f78a3184723182a834dfb234fc +2026-07-30-generated-third-party-notices.zh.md: b135c3626b661f1a5b0317a90b700a26d679bf1e diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index e480954d29..cbabd1142a 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -18,7 +18,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions was tried and does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. -The file discloses **direct** dependencies only. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. +The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail. **Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). @@ -26,10 +26,14 @@ The runtime tier deliberately covers **every mountable plugin**, not just what t The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. +The project owner separately authorizes distribution of every official `@anthropic-ai/claude-agent-sdk` version and the official Claude Code CLI/platform payloads that version declares through `optionalDependencies`. The generator represents this as one exact direct-package identity exception, not as a permissive-license override: `SEE LICENSE IN README.md` and `SEE LICENSE IN LICENSE.md` remain non-permissive classifications, and every unrelated non-permissive runtime still fails closed. When the SDK is present, the generator reads its installed manifest, rejects optional identities outside the official SDK payload prefix, derives the current SDK, CLI, and payload versions, verifies the installed host payload's identity, version, and declared-license field, and renders the complete SDK-declared payload set in a separate notices section. Version, declared-license, and payload-set changes do not require new identity authorization, but they still require ordinary dependency, lockfile, compatibility, terms, and notices review. + ## Testing The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts. It also pins the parsers against the shapes that would otherwise drop a package without a word: a `vendor/README.md` table that stops covering a vendored directory, a requirement array holding extras (`"httpx[http2]"`), a requirement with no version at all, an author-named `[dependency-groups]` table, and a workspace member area absent from any hardcoded list. Each of those is a silent-omission path, which is the failure mode a disclosure file cannot afford. +The Claude distribution tests prove that only the exact direct SDK identity bypasses the ordinary non-permissive-runtime rejection, that the bypass does not change license classification, and that the payload set comes from the SDK manifest rather than a version or platform allowlist. Wrong SDK identities, missing payloads, and unrelated optional package identities all fail. + ## Alternatives considered **Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases. @@ -42,6 +46,8 @@ The same spec that asserts freshness pins the tiering rule against fixture manif **Tier by reachability from the shipped assemblies only** (`apps/*` plus `python/sdk-runtime`). This produces a tighter runtime tier, but classifies the MCP client and the OpenTelemetry exporter as development-only even though a user running the installed repository can mount them. It understates the disclosure, which is the wrong direction to err for a legal notice. +**Treat the Claude SDK terms as permissive or add a reusable non-permissive allowlist.** Either shape would misstate the upstream declaration and let an unrelated runtime inherit authorization it was never granted. The narrow exception keys only the official direct SDK identity, while its optional payload identities are accepted solely as data declared by that SDK and remain visibly non-permissive. + **Emit the notices as a bilingual pair.** Every other root document is paired, but the file is a table of upstream package names, SPDX identifiers, and URLs; the translatable surface is a handful of section blurbs. `scripts/translation-pairing.ts` scopes discovery to `README*`, `.agents/notes/**`, `docs/**`, and `python/**`, so a root non-README file is outside the bilingual corpus by construction, and the README pair carries the bilingual entry points into it. ## Consequences @@ -51,3 +57,5 @@ A dependency edit now carries a regenerated notices file into the same commit. C The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. The tiering rule is a policy encoded in one constant. Adding a workspace area that never ships — a second test-infrastructure tier, another site — requires extending `DEV_ONLY_AREAS`, or its dependencies will be disclosed as runtime. + +The Claude identity exception is deliberately narrower than the payload disclosure it activates. Upgrading the SDK needs no new owner authorization, but regeneration fails unless the installed SDK exposes its version, CLI version, and at least one official platform payload, and unless the current host payload matches the SDK declaration. Maintainers still review changed terms and compatibility; the generator prevents the authorization from silently widening to another package. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 78ba7250e7..b135c3626b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -18,7 +18,7 @@ Status: implemented 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除**清单文件不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法试过,不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 -文件只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。 +文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。 **分层依据是声明方所在区域,而非清单字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一清单——即根清单、`packages/support/`、`packages/client/test-runtime/`、`website/`、`examples/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而 `bin/dsh` 启动器 exec 经过的 `tsx`,根本没有任何清单把它声明为运行时依赖,只能由生成器显式标记。 @@ -26,10 +26,14 @@ Status: implemented 清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 +项目所有者另行授权分发每个官方 `@anthropic-ai/claude-agent-sdk` 版本,以及该版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。生成器将其表示为一项精确匹配直接包身份的例外,而非宽松许可证覆盖项:`SEE LICENSE IN README.md` 与 `SEE LICENSE IN LICENSE.md` 仍归类为非宽松,所有无关的非宽松运行时依赖仍以默认拒绝方式失败。存在该 SDK 时,生成器会读取其已安装清单,拒绝不符合官方 SDK 载荷前缀的可选包身份,推导当前 SDK、CLI 与载荷版本,核验已安装宿主载荷的身份、版本和声明许可证字段,并在单独的声明章节中渲染 SDK 声明的完整载荷集合。版本、声明许可证和载荷集合发生变化时无需新的身份授权,但仍须经过常规的依赖、锁文件、兼容性、条款和声明评审。 + ## Testing 断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它还把各解析器钉在那些原本会让某个包无声消失的形态上:不再覆盖全部收编目录的 `vendor/README.md` 表、含 extras 的依赖数组(`"httpx[http2]"`)、完全不带版本的依赖、作者自取名字的 `[dependency-groups]` 表,以及任何硬编码列表都不含的工作区成员区域。这些都是静默漏报路径——正是披露文件最担不起的失败方式。 +Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通常的非宽松运行时拒绝;该绕过不会改变许可证分类;载荷集合来自 SDK 清单,而非版本或平台允许列表。SDK 身份错误、载荷缺失或存在无关的可选包身份时,测试都会失败。 + ## Alternatives considered **保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。 @@ -42,6 +46,8 @@ Status: implemented **只按已交付装配的可达性分层**(`apps/*` 加 `python/sdk-runtime`)。这样得到的运行时层更紧凑,但会把 MCP 客户端与 OpenTelemetry 导出器判为仅开发用途——而运行已安装仓库的用户完全可以挂载它们。这会低估披露,对法务通告来说错在了更危险的一侧。 +**将 Claude SDK 条款视为宽松条款,或新增可复用的非宽松允许列表。** 两种方案都会误述上游声明,并让无关运行时依赖继承从未授予它的授权。这项窄例外只匹配官方直接 SDK 身份;其可选载荷身份仅作为该 SDK 声明的数据被接受,并继续明确归类为非宽松。 + **把披露文件做成双语对。** 其他根文档都是成对的,但这份文件是上游包名、SPDX 标识与网址构成的表格,可翻译的只有寥寥几段章节导语。`scripts/translation-pairing.ts` 的发现范围限定在 `README*`、`.agents/notes/**`、`docs/**` 与 `python/**`,根目录下的非 README 文件在构造上就不属于双语语料;双语入口由 README 对承担。 ## Consequences @@ -51,3 +57,5 @@ Status: implemented 生成器需要已安装的工作树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 分层规则是编码在一个常量里的政策。若新增了不参与交付的工作区区域——第二层测试基础设施、另一个站点——就要同步扩展 `DEV_ONLY_AREAS`,否则其依赖会被当作运行时依赖披露出去。 + +Claude 身份例外刻意比其启用的载荷披露范围更窄。升级 SDK 无需新的所有者授权,但如果已安装的 SDK 未公开自身版本、CLI 版本和至少一个官方平台载荷,或当前宿主载荷与 SDK 声明不符,重新生成就会失败。维护者仍须评审发生变化的条款与兼容性;生成器会阻止授权悄然扩大到其他包。 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml deleted file mode 100644 index bde3f3cf11..0000000000 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 3b9fd51632439da5b3c3fd9187de552d6c9ca5e2 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 36be903640ad1c839d45ed1bf5e605f4e4d6e000 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md deleted file mode 100644 index 3b9fd51632..0000000000 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ /dev/null @@ -1,86 +0,0 @@ -# Agent Note: Claude Code and Codex subagent backends - -Status: proposed - -English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) - -## Problem - -The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. - -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official protocol, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot replace the former; a hand-mounted plugin cannot replace the latter. - -## Proposal - -The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. - -The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present. - -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. - -```text -fixed tool → shared subagent service → product provider → official product process - ← final answer / explicit error / cancellation ← terminal product fact - → foreground disposal → shared process-tree termination → whole-tree exit -``` - -### Ownership and lifecycle - -| Phase | Shared owner | Product-specific responsibility | Observable result | -| --- | --- | --- | --- | -| Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published | -| Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects | -| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive an existing shared stop reason; Codex uses `max-tokens` only for explicit context exhaustion | The parent receives only a final answer or an explicit failure | -| Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits | - -## Codex provider - -`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. - -Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. - -`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. - -For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. - -An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. - -## Claude Code provider - -The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented. - -## Evidence contract - -Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. - -The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Its Loader e2e resolves `@deepseek-ai/dsh-subagent-codex` by package name, verifies the `codex` registration and `subagent_codex` schema with background omitted, accepts `maxDepth: 'provider-managed'`, and records zero child starts while no `codex` command is available. The npm package is a development dependency for reproducible real-product evidence; production still supplies `codex` on `PATH`. - -The combined contract is complete only when the Claude sibling has equivalent real-product evidence and both public Loader configurations prove the fixed tools use the unchanged common subagent contract. - -## Alternatives considered - -**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. - -**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership before the two products demonstrate a missing common contract, so each private adapter calls the existing seams directly. - -**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. - -**Product doubles as required evidence.** Doubles are useful for exhaustive private protocol branches but do not prove package exports, official binaries, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. - -**Plugin-managed login, product home, models, or permissions.** Those settings would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. - -**Continuation, progress, background collection, and shared parent context.** The first user result needs one self-contained task and one final answer. Product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and background collection need separate user contracts and are not prebuilt. - -## Acceptance criteria - -Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required real-product specs return exact final answers or explicit failure or cancellation and prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. - -The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. - -## Risks - -- The product protocols are versioned and may change. Production performs no runtime version probe, so every supported baseline change requires refreshed compatibility evidence. -- Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's authority. -- Every delegation pays for a fresh process and independent model context, and only final text reaches the parent. -- Product tool or file side effects are not rolled back when a run fails or is cancelled. -- Unattended interaction denial prevents hidden approval hangs but cannot satisfy tasks that require new permission or human input. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md deleted file mode 100644 index 36be903640..0000000000 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ /dev/null @@ -1,86 +0,0 @@ -# Agent Note: Claude Code 与 Codex subagent 后端 - -Status: proposed - -[English](2026-08-04-claude-code-and-codex-subagent-backends.md) | 中文 - -## 问题 - -命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 - -产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据会分别证明两个事实:真实产品测试证明官方协议、原生身份验证形态、最终回答和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品。直接发起模型 HTTP 请求或使用产品替身无法取代前者,手工挂载插件则无法取代后者。 - -## 提案 - -harness 将两个一次性兄弟提供方发布为可独立安装的可选包。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,而 `subagent_claude_code` 绑定 `claude-code`。正式 CLI 的依赖闭包以及基础、Web 和 headless 配置都不加载这两个提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 - -Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。 - -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd,但不会复制父级对话。文档中的工具会关闭后台执行并使用 `maxDepth: 'provider-managed'`,让进程外产品自行负责递归策略,而不会向提供方发送其无法执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 - -```text -fixed tool → shared subagent service → product provider → official product process - ← final answer / explicit error / cancellation ← terminal product fact - → foreground disposal → shared process-tree termination → whole-tree exit -``` - -### 归属与生命周期 - -| 阶段 | 共享责任方 | 产品特定职责 | 可观察结果 | -| --- | --- | --- | --- | -| 解析 | `dsh-tool-subagent` 与 `ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 | -| 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 | -| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导出一种现有的共享停止原因;Codex 仅在明确发生上下文耗尽时使用 `max-tokens` | 父级只会收到最终回答或明确失败 | -| dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 | - -## Codex 提供方 - -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 - -发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 - -`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 - -对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 - -若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 - -## Claude Code 提供方 - -Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界,本 Agent Note 才能进入 implemented 状态。 - -## 证据契约 - -每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一项 Loader 装配 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。 - -Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader e2e 会按包名解析 `@deepseek-ai/dsh-subagent-codex`,验证 `codex` 注册与省略后台参数的 `subagent_codex` schema,接受 `maxDepth: 'provider-managed'`,并在环境中没有可用 `codex` 命令时记录零次子级启动。该 NPM 包是用于复现真实产品证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 - -只有在 Claude 兄弟提供方具备同等的真实产品证据,并且两个公开 Loader 配置都证明固定工具使用未变的通用 subagent 契约时,组合契约才算完整。 - -## 曾考虑的替代方案 - -**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展接入面,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都使用对应产品的官方接入方式。 - -**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。在两个产品尚未证明通用契约存在缺口时,新辅助包只会造成责任重复,因此各自的私有适配器会直接调用现有 seam。 - -**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 - -**以产品替身作为强制证据。** 替身有助于穷尽覆盖私有协议分支,但无法证明包导出、官方二进制程序、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 - -**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 - -**续接、进度、后台收集和共享父级上下文。** 首个用户结果只需要一项自包含任务和一个最终回答。产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集都需要独立的用户契约,本提案不会预先构建这些功能。 - -## 验收标准 - -两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,并证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 - -已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 - -## 风险 - -- 产品协议受版本约束,且可能发生变化。生产环境不会执行运行时版本探测,因此每次更改受支持的基线都必须刷新兼容性证据。 -- 产品原生配置使行为取决于部署环境中安装的产品与账户状态。强制测试会隔离这些输入,而生产环境会有意让产品继续负责它们。 -- 每次委派都要承担新建进程和独立模型上下文的开销,且只有最终文本会到达父级。 -- 运行失败或被取消时,产品工具或文件产生的副作用不会回滚。 -- 拒绝无人值守交互可以防止审批流程暗中挂起,但无法完成需要新权限或人工输入的任务。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5c1eb10acf..fc0600a4d4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml). @@ -32,6 +32,8 @@ External packages that a workspace package resolves at runtime. `scripts/install | Package | License | | --- | --- | | [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 | +| [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md | +| [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | | [`@babel/code-frame`](https://github.com/babel/babel) | MIT | | [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT | | [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | @@ -87,6 +89,24 @@ pnpm applies local patches to the following packages at install time, so shipped - `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch) +## Official Claude Code platform payloads + +The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. + +The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. + +| Optional platform package | Version | Declared license | +| --- | --- | --- | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | + + ## Development-only npm dependencies External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index c64e678b54..0dad00c0e5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -104,6 +104,7 @@ flowchart LR pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] pkg_subagent_codex["subagent-codex"] + pkg_subagent_claude_code["subagent-claude-code"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] @@ -225,6 +226,7 @@ flowchart LR pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents + pkg_subagent_claude_code --> svc_subagents pkg_subagent_codex --> svc_subagents pkg_subagent_dsh_sdk --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -315,6 +317,7 @@ flowchart LR svc_subprocess --> pkg_bash_sandbox svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp + svc_subprocess --> pkg_subagent_claude_code svc_subprocess --> pkg_subagent_codex svc_subprocess --> pkg_subagent_dsh_sdk svc_systemPrompt --> pkg_agent_loop @@ -376,7 +379,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, Claude Code, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | @@ -387,7 +390,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d063d793e6..08423c351d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1520,6 +1520,25 @@ export type PermissionPolicy = 'allow' | 'reject' Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) +## `@deepseek-ai/dsh-subagent-claude-code` + +Requires: `subagents` · `subprocess` + +```ts config-catalog +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for Claude Code process-tree termination. */ + disposeGraceMs?: number +} +``` + +Source: [`packages/subagent/subagent-claude-code/src/index.ts:31`](../packages/subagent/subagent-claude-code/src/index.ts) + ## `@deepseek-ai/dsh-subagent-codex` Requires: `subagents` · `subprocess` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 0582fb9dac..a875637d01 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 820d7fce8560028f592ec101f4f222013105f035 -extension-cookbook.zh.md: bb48f584729ad3a67fd43ca8c4c9f6f4902de656 +extension-cookbook.md: f9328220a3d2776cc6576f9478215be4f2fe51e1 +extension-cookbook.zh.md: 52b67de97b5adbf2b85a60a613f49776d60673cb diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 820d7fce85..f9328220a3 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -118,7 +118,7 @@ Every product feature maps to a listener on a documented extension seam — the | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | -| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-dsh-sdk`) + `dsh-tool-subagent` exposing one configured provider to the model | +| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-claude-code`/`-dsh-sdk`) + `dsh-tool-subagent` exposing one configured provider to the model | | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index bb48f58472..52b67de97b 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -118,7 +118,7 @@ export function apply(ctx: Context) { | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | -| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-dsh-sdk`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | +| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-claude-code`/`-dsh-sdk`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 6bd775a84c..2348c83909 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: c810ae40f57a0f84f1a6b53e092d6bec88af206f -subagent.zh.md: efeec0a0f20b7ac85020df012878cf41f264073d +subagent.md: 9070c64da6cf2fa5a95074acb65f475bc9c05035 +subagent.zh.md: 9819e6f707770caf208697f977d683103d2783e1 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index c810ae40f5..9070c64da6 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,7 +4,7 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index efeec0a0f2..9819e6f707 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为五个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为六个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml new file mode 100644 index 0000000000..2bfcd2af3f --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -0,0 +1,40 @@ +# Test-only composition of both public opt-in providers and foreground tools. +# The owning e2e boots this tree but never invokes a model or product process. +- id: fixture + name: './fixture.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: 'provider-managed' + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'This composition test must not start a model turn.' + workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts new file mode 100644 index 0000000000..d7540e1a2d --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** Inspect both public product-provider compositions without invoking them. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-tools' + +const configPath = process.argv[2] +if (configPath === undefined) { + throw new Error('product-provider Loader composition driver requires a config path') +} + +let starts = 0 +const ctx = await boot( + 'product-provider-loader-composition', + resolveConfigPath(configPath, undefined), + undefined, + (hostCtx) => { + hostCtx.on('subagent/start', () => { + starts += 1 + }) + }, +) + +try { + const providerNames = ['codex', 'claude-code'] as const + const toolNames = ['subagent_codex', 'subagent_claude_code'] as const + const providers = providerNames.map((providerName) => { + const provider = ctx.subagents.getProvider(providerName) + if (provider === undefined) { + throw new Error(`${providerName} provider was not registered`) + } + return { + name: provider.name, + capabilities: provider.capabilities, + inheritsParentContext: provider.inheritsParentContext, + } + }) + const tools = toolNames.map((toolName) => { + const tool = ctx.tools.schemas().find(schema => schema.name === toolName) + if (tool === undefined) throw new Error(`${toolName} tool was not registered`) + const properties = tool.parameters.properties + if ( + typeof properties !== 'object' + || properties === null + || Array.isArray(properties) + ) { + throw new Error(`${toolName} has invalid parameter properties`) + } + return { + name: tool.name, + parameterNames: Object.keys(properties).sort(), + required: tool.parameters.required, + } + }) + + process.stdout.write(`${JSON.stringify({ + registeredProviders: ctx.subagents.list(), + providers, + tools, + starts, + })}\n`) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts new file mode 100644 index 0000000000..a9f9cd5997 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts @@ -0,0 +1,7 @@ +/** Reuse the composition-only parent adapter shared by the product providers. */ + +export { + apply, + inject, + name, +} from '../subagent-codex/fixture.ts' diff --git a/examples/package.json b/examples/package.json index bc697ab11c..513d0e4a2f 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:*", "@deepseek-ai/dsh-subagent-codex": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", diff --git a/knip.json b/knip.json index 1d99ae4cd4..5c0978d01c 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,8 @@ "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", + "acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts", + "acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts", "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", "acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", @@ -555,6 +557,16 @@ "@openai/codex" ] }, + "packages/subagent/subagent-claude-code": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 875a9c93a7..f3389b821f 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/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/README.md -README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5 -README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd +README.md: 75d90616e25f74761ac4f78c429a2e0c1aabfb38 +README.zh.md: f22a5ecb4fc5d1324a5abe5afb16f34044d5312e diff --git a/packages/subagent/README.md b/packages/subagent/README.md index abe1432d3c..75d90616e2 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -12,11 +12,12 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | | `subagent-codex/` | Out-of-process backend: a real Codex app-server process with one ephemeral thread and turn | (registers on `ctx.subagents`) | +| `subagent-claude-code/` | Out-of-process backend: the official Claude Agent SDK with one real Claude Code CLI query | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) | -The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-claude-code` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 3df2b6c62d..f22a5ecb4f 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -12,11 +12,12 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | | `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | | `subagent-codex/` | 进程外后端:一个真实的 Codex app-server 进程,包含一个临时 thread 和一个轮次 | (注册到 `ctx.subagents`) | +| `subagent-claude-code/` | 进程外后端:使用官方 Claude Agent SDK 与一次真实 Claude Code CLI query | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) | | `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) | -接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-claude-code` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml new file mode 100644 index 0000000000..ac60e83f76 --- /dev/null +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md +README.md: facaae300eeb8907076182a129aa216863dec8ec +README.zh.md: 75627cb54032edde07ec1ba5e21a058768515e29 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md new file mode 100644 index 0000000000..facaae300e --- /dev/null +++ b/packages/subagent/subagent-claude-code/README.md @@ -0,0 +1,96 @@ +# @deepseek-ai/dsh-subagent-claude-code + +English | [中文](README.zh.md) + +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. + +## Start and ownership + +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`. + +The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; this version produces neither `max-tokens` nor `refusal`. + +Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate. + +## Native settings and interaction + +The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. + +Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own. + +## Capabilities and context + +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | +| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | + +Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. + +Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. + +```yaml +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +## Product compatibility and evidence + +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. The real-product spec drives that official SDK and CLI against a loopback Anthropic Messages SSE service with a non-empty fake key. It proves the exact task, authentication, byte-exact answer, inherited host-setting marker, process failure, cancellation, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration alongside the Codex provider with neither product command available, verifies both fixed foreground-only tools, and records zero child starts. + +The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. + +## Model Experience + +### Child request + +#### What the model sees + +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation. + +#### Token effect + +The child pays for an independent Claude Code context and query. Child tokens do not enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Reuse depends only on Claude Code's own model, instructions, tools, native settings, and fresh query. + +### Parent tool result, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, the parent sees only the strict final Claude Code answer or the consumer's exact error for a non-completed result. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. + +#### Token effect + +Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself. + +#### KV Cache effect + +Append-only: the new tool result follows the reusable parent request prefix. + +## Known Limitations and Deferred Work + +- **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. +- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. +- **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. +- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md new file mode 100644 index 0000000000..75627cb540 --- /dev/null +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -0,0 +1,96 @@ +# @deepseek-ai/dsh-subagent-claude-code + +[English](README.md) | 中文 + +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。 + +## 启动与所有权 + +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 + +SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`;本版本不会产生 `max-tokens` 或 `refusal`。 + +本地取消会在结果竞态中胜出并映射为 `aborted`。`dispose()` 具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。 + +## 原生设置与交互 + +提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。 + +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 + +## 能力与上下文 + +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | +| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | + +生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。 + +请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 + +```yaml +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +## 产品兼容性与证据 + +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。真实产品测试使用非空伪密钥,驱动该官方 SDK 与 CLI 连接回环 Anthropic Messages SSE 服务,并证明原始任务、身份验证、逐字节一致的答案、继承的宿主设置标记、进程失败、取消与进程树退出。独立的 Loader 装配 e2e 会在两个产品命令均不可用时,与 Codex 提供方一起启动和 README 所示形态相同的用户配置,验证两个固定且只支持前台执行的工具,并记录零次子级启动。 + +项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 + +## 模型体验 + +### 子任务请求 + +#### 模型看到的内容 + +Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。 + +#### 对 token 的影响 + +子任务需为独立的 Claude Code 上下文和 query 承担 token 开销。子任务 token 不会进入父级上下文。 + +#### 对 KV Cache 的影响 + +这与父请求缓存相互独立。能否复用只取决于 Claude Code 自身的模型、指令、工具、原生设置和全新 query。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父级模型只会看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 + +#### 对 token 的影响 + +父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。 + +#### 对 KV Cache 的影响 + +仅追加:新的工具结果接在可复用的父请求前缀之后。 + +## 已知限制与后续工作 + +- **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 +- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 +- **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json new file mode 100644 index 0000000000..f3dad8d5bd --- /dev/null +++ b/packages/subagent/subagent-claude-code/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-subagent-claude-code", + "description": "One-shot Claude Code subagent provider over the official Agent SDK", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "@anthropic-ai/sdk": "0.93.0", + "@anthropic-ai/claude-agent-sdk": "0.3.220", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts new file mode 100644 index 0000000000..fe6d0a89a0 --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -0,0 +1,95 @@ +/** + * Fixed Claude Code one-shot subagent provider. Every accepted run invokes + * the official Agent SDK in the delegating Session's workspace and places + * the SDK-spawned real CLI under the shared subprocess owner. + * + * @module @deepseek-ai/dsh-subagent-claude-code + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { + assertPositiveFinite, + NO_START_CAPABILITIES, + resolveChildCwd, + type ResolvedSubagentStartRequest, + type SubagentCapabilities, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { + DEFAULT_DISPOSE_GRACE_MS, + startClaudeCodeRun, + type ClaudeCodeRunSpec, +} from './run.ts' + +export const name = 'subagent-claude-code' +export const inject = ['subagents', 'subprocess'] + +/* jscpd:ignore-start -- sibling product providers intentionally expose the + * same two deployment-owned fields without adding a shared config owner. */ +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for Claude Code process-tree termination. */ + disposeGraceMs?: number +} + +export const Config: z = z.object({ + env: z.dict(z.string()).default({}), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), +}) + +type ResolvedConfig = Required +/* jscpd:ignore-end */ + +/* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror + * the Codex sibling; each product's lifecycle remains package-private. */ +class ClaudeCodeProvider implements SubagentProvider { + readonly name = 'claude-code' + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + + constructor( + private readonly ctx: Context, + private readonly config: ResolvedConfig, + ) {} + + start(request: ResolvedSubagentStartRequest) { + const spec: ClaudeCodeRunSpec = { + cwd: resolveChildCwd( + 'subagent-claude-code', + undefined, + request.parent.session.header.cwd, + ), + env: this.config.env, + disposeGraceMs: this.config.disposeGraceMs, + spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), + onError: (error, stopReason) => { + this.ctx.logger.warn( + `subagent-claude-code: child run failed (${stopReason}): ${error.message}`, + ) + }, + } + return startClaudeCodeRun(request, spec) + } +} + +/** + * Register the fixed `claude-code` provider. + * @param ctx - context carrying shared subagent and subprocess services. + * @param config - explicit child environment and disposal grace. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite( + 'subagent-claude-code', + 'disposeGraceMs', + resolved.disposeGraceMs, + ) + ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved)) +} +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts new file mode 100644 index 0000000000..462692590f --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for + * `@deepseek-ai/dsh-subagent-claude-code`. + * @module @deepseek-ai/dsh-subagent-claude-code/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-claude-code' + +/** Cordis companion plugin name. */ +export const name = 'subagent-claude-code-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: lifecycle pairing belongs to the shared subagent + * service and process-tree ownership belongs to the subprocess service. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - plugin context carrying the invariant registry. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts new file mode 100644 index 0000000000..b8b09216fa --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -0,0 +1,159 @@ +/** + * Projection from the shared managed-process handle to the official Claude + * Agent SDK's custom-spawn process interface. + * + * @module @deepseek-ai/dsh-subagent-claude-code/process + */ + +import { EventEmitter } from 'node:events' +import type { + SpawnedProcess, + SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import type { + SubprocessHandle, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' + +function thrown(value: unknown): Error { + /* v8 ignore next -- the subprocess seam rejects with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Convert the SDK environment to the shared subprocess seam's defined-value + * overlay without changing the effective child environment. + * @param env - SDK-composed child environment. + * @returns entries whose values survive Node's subprocess environment. + */ +export function definedEnvironment( + env: SpawnOptions['env'], +): Record { + const defined: Record = {} + for (const [name, value] of Object.entries(env)) { + if (value !== undefined) defined[name] = value + } + return defined +} + +/** + * Translate one official SDK spawn request to the shared process owner. + * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. + * @param graceMs - process-tree termination grace. + * @returns the fully explicit shared subprocess request. + */ +export function claudeSpawnSpec( + options: SpawnOptions, + graceMs: number, +): SubprocessSpawnSpec { + if (options.cwd === undefined || options.cwd.length === 0) { + throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') + } + return { + argv: [options.command, ...options.args], + cwd: options.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs, + signal: options.signal, + env: definedEnvironment(options.env), + } +} + +/** + * SDK-facing view of one shared managed process. Protocol transport remains + * in the official SDK; this adapter only projects streams and exit events. + */ +export class ManagedClaudeCodeProcess implements SpawnedProcess { + readonly stdin + readonly stdout + private readonly events = new EventEmitter() + private exitCodeValue: number | null = null + private signalCodeValue: NodeJS.Signals | null = null + private killRequested = false + + /** + * Project a managed process with piped stdin and stdout. + * @param child - shared handle that remains the process-tree authority. + */ + constructor(private readonly child: SubprocessHandle) { + if (child.stdin === undefined || child.stdout === undefined) { + throw new Error('subagent-claude-code: SDK child requires piped stdin and stdout') + } + this.stdin = child.stdin + this.stdout = child.stdout + // EventEmitter gives `error` special throw semantics without a listener. + // The SDK attaches its listener synchronously after custom spawn returns, + // while this no-op also contains an already-rejected spawn handle. + this.events.on('error', () => {}) + void child.done.then( + (outcome) => { + this.exitCodeValue = outcome.exitCode + this.signalCodeValue = outcome.signal + this.events.emit('exit', outcome.exitCode, outcome.signal) + }, + (error: unknown) => { + this.events.emit('error', thrown(error)) + }, + ) + } + + /** Whether the SDK has requested managed tree termination. */ + get killed(): boolean { + return this.killRequested + } + + /** Direct-child exit code, or null while running or after signal exit. */ + get exitCode(): number | null { + return this.exitCodeValue + } + + /** Direct-child terminating signal, if any. */ + get signalCode(): NodeJS.Signals | null { + return this.signalCodeValue + } + + /** + * Route the SDK's termination request to the tree-scoped process owner. + * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. + * @returns false only after exit or a previous termination request. + */ + kill(_signal: NodeJS.Signals): boolean { + if ( + this.killRequested + || this.exitCodeValue !== null + || this.signalCodeValue !== null + ) { + return false + } + this.killRequested = true + this.child.terminate() + return true + } + + /** Register a persistent process lifecycle listener. */ + on( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.on(event, listener) + } + + /** Register a one-shot process lifecycle listener. */ + once( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.once(event, listener) + } + + /** Remove a process lifecycle listener. */ + off( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.off(event, listener) + } +} diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts new file mode 100644 index 0000000000..a65f6f5497 --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -0,0 +1,357 @@ +/** + * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its + * real CLI process under the shared subprocess owner, map only strict SDK + * success to completion, and dispose to whole-tree quiescence. + * + * @module @deepseek-ai/dsh-subagent-claude-code/run + */ + +import { randomUUID } from 'node:crypto' +import { + query as officialQuery, + type Options, + type Query, + type SDKMessage, + type SDKResultMessage, + type SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + settleRunResult, + subprocessRunHandle, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, + type SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' +import { + scrubbedParentEnv, + type SubprocessHandle, + type SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' +import { + claudeSpawnSpec, + ManagedClaudeCodeProcess, +} from './process.ts' + +/** Default POSIX grace between subprocess termination tiers. */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +/** + * Bound final exit observation at twice a positive finite grace without + * narrowing the public config to Node's single-timer integer range. + */ +function doubledGraceWindow(graceMs: number): { + readonly signal: AbortSignal + readonly cancel: () => void +} { + const whole = Math.floor(graceMs) + let remaining = BigInt(whole) * 2n + + BigInt(Math.ceil((graceMs - whole) * 2)) + const controller = new AbortController() + let timer: ReturnType | undefined + const arm = (): void => { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + timer = setTimeout(() => { + timer = undefined + if (remaining === 0n) { + controller.abort() + } else { + arm() + } + }, Number(chunk)) + } + arm() + return { + signal: controller.signal, + cancel: () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + }, + } +} + +type QueryFactory = (params: { + prompt: string + options: Options +}) => Query + +/** Fully resolved inputs for one official Claude Agent SDK query. */ +export interface ClaudeCodeRunSpec { + /** Parent Session workspace supplied to the SDK and real CLI. */ + readonly cwd: string + /** Explicit deployment/test environment layered after shared scrubbing. */ + readonly env: Record + /** Subprocess termination grace and final tree-exit bound. */ + readonly disposeGraceMs: number + /** Shared subprocess service spawn operation. */ + readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle + /** Official query entrypoint; replaced only by package-local unit tests. */ + readonly query?: QueryFactory + /** Diagnostic sink for a post-publication error flattened into a result. */ + readonly onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- SDK and subprocess failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Validate and preserve the one-shot task before crossing the SDK boundary. + * @param prompt - task content accepted from the shared subagent service. + * @returns the exact text sequence as one SDK prompt. + */ +export function textTask(prompt: readonly ContentBlock[]): string { + if (prompt.length === 0) { + throw new Error('subagent-claude-code: the one-shot task must contain only text blocks') + } + const texts: string[] = [] + for (const block of prompt) { + if (block.type !== 'text') { + throw new Error('subagent-claude-code: the one-shot task must contain only text blocks') + } + texts.push(block.text) + } + if (texts.every(text => text.trim().length === 0)) { + throw new Error('subagent-claude-code: the one-shot task must not be empty') + } + return texts.join('') +} + +/** + * Strictly derive the only SDK result that can complete a shared run. + * @param message - an official discriminated result union. + * @returns exact final text for a successful, non-error result. + */ +export function successfulResult(message: SDKResultMessage): string { + if ( + message.subtype !== 'success' + || message.is_error + || message.result.trim().length === 0 + ) { + const detail = message.subtype === 'success' + ? 'success result was marked as an error or contained no answer' + : message.errors.join('; ') || message.subtype + throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`) + } + return message.result +} + +/** + * Consume the complete SDK stream and require one strict success plus normal + * iterator completion. + * @param query - published official SDK query. + * @param setOutput - captures the candidate result for error diagnostics. + * @returns the completed shared result. + */ +export async function consumeClaudeQuery( + query: AsyncIterable, + setOutput: (output: ContentBlock[]) => void, +): Promise { + let answer: string | undefined + for await (const message of query) { + if (message.type !== 'result') continue + answer = successfulResult(message) + setOutput([{ type: 'text', text: answer }]) + } + if (answer === undefined) { + throw new Error('subagent-claude-code: Claude Code ended without a result') + } + return { + output: [{ type: 'text', text: answer }], + stopReason: 'completed', + } +} + +/** + * Close the official query, terminate the managed process tree, and wait for + * the subprocess owner to prove it is gone. + * @param query - official SDK query, when creation reached that point. + * @param child - shared-service handle that owns the CLI process tree. + * @param graceMs - termination grace used to bound final exit observation. + */ +export async function disposeClaudeCodeChild( + query: Pick | undefined, + child: SubprocessHandle, + graceMs: number, +): Promise { + const failures: Error[] = [] + let treeExited = child.pid <= 0 + try { + query?.close() + } catch (error: unknown) { + failures.push(thrown(error)) + } + + if (child.pid > 0) { + child.terminate() + const exitWindow = doubledGraceWindow(graceMs) + try { + treeExited = await child.waitForExit(exitWindow.signal) + if (!treeExited) { + failures.push(new Error( + 'subagent-claude-code: Claude Code process tree did not exit within its dispose window', + )) + } + } catch (error: unknown) { + failures.push(thrown(error)) + } finally { + exitWindow.cancel() + } + } + if (treeExited) { + try { + await child.done + } catch (error: unknown) { + failures.push(thrown(error)) + } + } else { + // The bounded tree observation owns teardown completion. Keep a later + // direct-child spawn failure observed without turning that bound into an + // unbounded wait. + void child.done.catch(() => {}) + } + + const firstFailure = failures[0] + if (failures.length === 1 && firstFailure !== undefined) throw firstFailure + if (failures.length > 1) { + throw new AggregateError( + failures, + 'subagent-claude-code: query and process cleanup failed', + ) + } +} + +/** + * Build the fixed official SDK options for one one-shot provider run. + * @param spec - workspace, environment, process seam, and disposal policy. + * @param controller - per-run cancellation owner. + * @param capture - receives the real managed child synchronously from the SDK hook. + * @returns options that inherit native settings while disabling persistence and user questions. + */ +export function claudeQueryOptions( + spec: ClaudeCodeRunSpec, + controller: AbortController, + capture: (child: SubprocessHandle) => void, +): Options { + return { + abortController: controller, + cwd: spec.cwd, + env: { ...scrubbedParentEnv(), ...spec.env }, + persistSession: false, + disallowedTools: ['AskUserQuestion'], + spawnClaudeCodeProcess: (options: SpawnOptions) => { + const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs)) + capture(child) + return new ManagedClaudeCodeProcess(child) + }, + } +} + +/** + * Start one official Claude Agent SDK query and publish its one-shot run. + * @param request - resolved shared subagent request. + * @param spec - workspace, environment, process seam, and diagnostic policy. + * @returns the published run after both Query and real CLI handle exist. + */ +export async function startClaudeCodeRun( + request: SubagentStartRequest, + spec: ClaudeCodeRunSpec, +): Promise { + const prompt = textTask(request.prompt) + if (request.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + + const controller = new AbortController() + const requestCancel = (): void => { + if (!controller.signal.aborted) { + controller.abort(new Error('subagent-claude-code: run cancelled locally')) + } + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + let child: SubprocessHandle | undefined + let query: Query | undefined + try { + query = (spec.query ?? officialQuery)({ + prompt, + options: claudeQueryOptions(spec, controller, (captured) => { + child = captured + }), + }) + if (child === undefined || child.pid <= 0) { + throw new Error( + 'subagent-claude-code: official SDK did not publish a controllable Claude Code process', + ) + } + if (controller.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + const cancelledBeforeCleanup = controller.signal.aborted + requestCancel() + if (child !== undefined) { + try { + await disposeClaudeCodeChild(query, child, spec.disposeGraceMs) + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-claude-code: startup failed and CLI cleanup also failed', + ) + } + } else if (query !== undefined) { + try { + query.close() + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-claude-code: startup failed and query cleanup also failed', + ) + } + } + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. + if (cancelledBeforeCleanup || request.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + throw thrown(error) + } + + let output: ContentBlock[] = [] + const publishedQuery = query + const publishedChild = child + const result = settleRunResult({ + attempt: () => consumeClaudeQuery(publishedQuery, (value) => { + output = value + }), + collectOutput: () => output, + cancelled: () => controller.signal.aborted, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: () => disposeClaudeCodeChild( + publishedQuery, + publishedChild, + spec.disposeGraceMs, + ), + }) +} diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..51a2ea0025 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -0,0 +1,72 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/', + import.meta.url, +)) +const driver = join(fixtureDir, 'driver.ts') +const configPath = join(fixtureDir, 'cordis.yml') +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +describe('product-provider public Loader composition', () => { + it('loads both opt-in packages and foreground tools without starting either product', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'product-provider Loader composition', + tempDirPrefix: 'dsh-product-provider-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + // Loading either optional package must not probe or start its binary. + PATH: '', + }, + }) + + expect(stderr).toBe('') + expect(JSON.parse(stdout)).toEqual({ + registeredProviders: ['codex', 'claude-code'], + providers: [ + { + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + { + name: 'claude-code', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + ], + tools: [ + { + name: 'subagent_codex', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + { + name: 'subagent_claude_code', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + ], + starts: 0, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts new file mode 100644 index 0000000000..720954f153 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -0,0 +1,163 @@ +import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'node:http' + +/** One deterministic response emitted by the package-private Messages server. */ +export type MessagesBehavior = + | { readonly kind: 'complete'; readonly text: string } + | { readonly kind: 'hold' } + +/** One recorded Anthropic Messages request. */ +interface RecordedMessagesRequest { + readonly method: string + readonly path: string + readonly headers: IncomingHttpHeaders + readonly body: Record +} + +/** Running package-private Anthropic Messages fixture. */ +export interface MessagesFixture { + readonly baseUrl: string + readonly requests: RecordedMessagesRequest[] + readonly requestStarted: Promise + close(): Promise +} + +function event( + response: ServerResponse, + type: string, + payload: Record, +): void { + response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`) +} + +function complete( + response: ServerResponse, + body: Record, + text: string, +): void { + const model = typeof body.model === 'string' ? body.model : 'fixture-model' + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + event(response, 'message_start', { + type: 'message_start', + message: { + id: 'msg_dsh_fixture', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 7, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + event(response, 'content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }) + event(response, 'content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text }, + }) + event(response, 'content_block_stop', { + type: 'content_block_stop', + index: 0, + }) + event(response, 'message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 1 }, + }) + event(response, 'message_stop', { type: 'message_stop' }) + response.end() +} + +/** + * Start a loopback-only Anthropic Messages SSE fixture. + * @param script - one behavior per Messages request. + * @returns the bound server and its recorded requests. + */ +export async function startMessagesFixture( + script: readonly MessagesBehavior[], +): Promise { + const requests: RecordedMessagesRequest[] = [] + let requestStartedResolve!: () => void + const requestStarted = new Promise((resolve) => { + requestStartedResolve = resolve + }) + let behaviorIndex = 0 + const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + request.on('end', () => { + const path = request.url ?? '' + if (!path.startsWith('/v1/messages')) { + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + type: 'error', + error: { type: 'not_found_error', message: `unexpected path ${path}` }, + })) + return + } + const text = Buffer.concat(chunks).toString('utf8') + const body = JSON.parse(text) as Record + requests.push({ + method: request.method ?? '', + path, + headers: request.headers, + body, + }) + requestStartedResolve() + const behavior = script[behaviorIndex++] + if (behavior === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + type: 'error', + error: { + type: 'api_error', + message: 'Messages fixture script was exhausted', + }, + })) + return + } + if (behavior.kind === 'complete') { + complete(response, body, behavior.text) + } + // A hold deliberately leaves the response pending until client abort. + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('Messages fixture did not bind a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + requestStarted, + async close(): Promise { + server.closeAllConnections() + await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + }) + }, + } +} diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts new file mode 100644 index 0000000000..99b0a69a20 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -0,0 +1,226 @@ +import { execFile } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as claudeCode from '../src/index.ts' +import { + startMessagesFixture, + type MessagesBehavior, + type MessagesFixture, +} from './messages-fixture.ts' + +const execFileAsync = promisify(execFile) +const sdkRoot = dirname(fileURLToPath( + import.meta.resolve('@anthropic-ai/claude-agent-sdk'), +)) +const sdkPackage = JSON.parse(readFileSync( + join(sdkRoot, 'package.json'), + 'utf8', +)) as { + version: string + claudeCodeVersion: string + optionalDependencies: Record +} +const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}` +const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!) +const claudeBin = join( + platformRoot, + process.platform === 'win32' ? 'claude.exe' : 'claude', +) +const settingsModel = 'dsh-settings-inheritance-marker' +const fakeKey = 'dsh-fake-anthropic-key' + +const roots: string[] = [] +const fixtures: MessagesFixture[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +interface RealHarness { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly parent: Agent + readonly workspace: string + readonly env: Record +} + +async function realHarness(script: readonly MessagesBehavior[]): Promise<{ + readonly harness: RealHarness + readonly fixture: MessagesFixture +}> { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-')) + roots.push(root) + const workspace = join(root, 'workspace') + const claudeConfig = join(root, 'claude-config') + const xdgConfig = join(root, 'xdg') + mkdirSync(workspace) + mkdirSync(claudeConfig) + mkdirSync(xdgConfig) + writeFileSync( + join(claudeConfig, 'settings.json'), + `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, + ) + const fixture = await startMessagesFixture(script) + fixtures.push(fixture) + const env = { + ANTHROPIC_API_KEY: fakeKey, + ANTHROPIC_BASE_URL: fixture.baseUrl, + CLAUDE_CONFIG_DIR: claudeConfig, + HOME: root, + XDG_CONFIG_HOME: xdgConfig, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1', + DISABLE_TELEMETRY: '1', + DISABLE_ERROR_REPORTING: '1', + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 }) + const parent = { + id: 'real-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + return { + harness: { ctx, handles, parent, workspace, env }, + fixture, + } +} + +async function expectQuiescent( + handles: readonly SubprocessHandle[], +): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + const outcome = await handle.done + expect(outcome).toHaveProperty('exitCode') + expect(outcome).toHaveProperty('signal') + } +} + +function startRequest( + harness: RealHarness, + prompt: string, + signal = new AbortController().signal, +) { + return harness.ctx.subagents.start('claude-code', { + prompt: [{ type: 'text', text: prompt }], + parent: harness.parent, + signal, + }) +} + +describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { + timeout: 60_000, +}, () => { + it('inherits host settings and sends the exact task and fake key to local Messages', async () => { + const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220' + const task = 'Return the fixture sentinel exactly.' + const { harness, fixture } = await realHarness([ + { kind: 'complete', text: sentinel }, + ]) + expect(sdkPackage.version).toBe('0.3.220') + expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') + expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') + const version = await execFileAsync(claudeBin, ['--version'], { + env: { ...process.env, ...harness.env }, + }) + expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') + + const run = await startRequest(harness, task) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(fixture.requests).toHaveLength(1) + const recorded = fixture.requests[0]! + expect(recorded.method).toBe('POST') + expect(recorded.path).toMatch(/^\/v1\/messages(?:\\?|$)/) + expect(recorded.headers['x-api-key']).toBe(fakeKey) + expect(recorded.body.model).toBe(settingsModel) + expect(Array.isArray(recorded.body.messages)).toBe(true) + const messageTexts = ( + recorded.body.messages as Array<{ content?: unknown }> + ).flatMap((message): unknown[] => + Array.isArray(message.content) ? message.content as unknown[] : []) + .filter((block): block is { type: string; text: string } => + typeof block === 'object' + && block !== null + && 'type' in block + && block.type === 'text' + && 'text' in block + && typeof block.text === 'string') + .map(block => block.text) + expect(messageTexts.filter(text => text.includes(task))).toEqual([task]) + await expectQuiescent(harness.handles) + }) + + it('maps a real CLI process failure to error', async () => { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const run = await startRequest(harness, 'Exercise the failure path.') + await fixture.requestStarted + expect(harness.handles).toHaveLength(1) + harness.handles[0]!.terminate() + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await run.dispose() + expect(fixture.requests).toHaveLength(1) + expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey) + await expectQuiescent(harness.handles) + }) + + it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const controller = new AbortController() + const run = await startRequest( + harness, + 'Wait for cancellation.', + controller.signal, + ) + await fixture.requestStarted + controller.abort(new Error('real product cancellation')) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await run.dispose() + await expectQuiescent(harness.handles) + }) +}) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts new file mode 100644 index 0000000000..1f02914163 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -0,0 +1,845 @@ +import { PassThrough } from 'node:stream' +import type { + Query, + SDKMessage, + SDKResultMessage, + SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { + SubprocessHandle, + SubprocessOutcome, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as claudeCode from '../src/index.ts' +import * as invariant from '../src/invariant.ts' +import { + claudeSpawnSpec, + definedEnvironment, + ManagedClaudeCodeProcess, +} from '../src/process.ts' +import { + claudeQueryOptions, + consumeClaudeQuery, + disposeClaudeCodeChild, + startClaudeCodeRun, + successfulResult, + textTask, + type ClaudeCodeRunSpec, +} from '../src/run.ts' + +const fakeParent = { + id: 'parent', + session: { header: { cwd: process.cwd() } }, +} as unknown as Agent + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }], + signal = new AbortController().signal, +) { + return { prompt, parent: fakeParent, signal } +} + +async function nextTask(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} + +interface FakeChildOptions { + readonly pid?: number + readonly stdin?: PassThrough | undefined + readonly stdout?: PassThrough | undefined + readonly exitOnTerminate?: boolean + readonly waitForExitResult?: boolean + readonly waitForExitError?: Error + readonly doneError?: Error +} + +interface FakeChild { + readonly handle: SubprocessHandle + readonly stdin: PassThrough + readonly stdout: PassThrough + readonly settle: (outcome?: SubprocessOutcome) => void + readonly fail: (error: Error) => void + readonly terminate: Mock + readonly waitForExit: Mock +} + +function fakeChild(options: FakeChildOptions = {}): FakeChild { + const stdin = new PassThrough() + const stdout = new PassThrough() + let exited = false + let resolveDone!: (outcome: SubprocessOutcome) => void + let rejectDone!: (error: Error) => void + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + // Individual tests deliberately exercise rejected and still-pending handles. + void done.catch(() => {}) + const settle = ( + outcome: SubprocessOutcome = { exitCode: 0, signal: null }, + ): void => { + if (exited) return + exited = true + resolveDone(outcome) + } + const fail = (error: Error): void => { + if (exited) return + exited = true + rejectDone(error) + } + if (options.doneError !== undefined) fail(options.doneError) + const terminate = vi.fn(() => { + if (options.exitOnTerminate !== false) settle() + }) + const waitForExit = vi.fn(async (signal?: AbortSignal): Promise => { + if (options.waitForExitError !== undefined) { + throw options.waitForExitError + } + if (options.waitForExitResult !== undefined) { + return options.waitForExitResult + } + if (exited) return true + if (signal === undefined) { + await done.catch(() => {}) + return true + } + return await new Promise((resolve) => { + const onAbort = (): void => { resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + void done.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + ) + }) + }) + const handle: SubprocessHandle = { + pid: options.pid ?? 1234, + stdin: options.stdin === undefined ? stdin : options.stdin, + stdout: options.stdout === undefined ? stdout : options.stdout, + stderr: undefined, + collected: {}, + done, + terminate, + waitForExit, + } + return { + handle, + stdin, + stdout, + settle, + fail, + terminate, + waitForExit, + } +} + +function success( + result = 'answer', + isError = false, +): SDKResultMessage { + return { + type: 'result', + subtype: 'success', + is_error: isError, + result, + } as SDKResultMessage +} + +type ErrorSubtype = Exclude + +function failure( + subtype: ErrorSubtype, + errors: string[] = ['fixture failure'], +): SDKResultMessage { + return { + type: 'result', + subtype, + is_error: true, + errors, + } as SDKResultMessage +} + +function queryFrom( + messages: readonly SDKMessage[], + after?: Error, + close = vi.fn(), +): Query { + async function* stream(): AsyncGenerator { + for (const message of messages) yield message + if (after !== undefined) throw after + } + return Object.assign(stream(), { close }) as unknown as Query +} + +function waitingQuery(signal: AbortSignal, close = vi.fn()): Query { + async function* stream(): AsyncGenerator { + await new Promise((_resolve, reject) => { + const fail = (): void => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) + } + if (signal.aborted) fail() + else signal.addEventListener('abort', fail, { once: true }) + }) + } + return Object.assign(stream(), { close }) as unknown as Query +} + +function sdkSpawnOptions( + overrides: Partial = {}, +): SpawnOptions { + return { + command: '/sdk/claude', + args: ['--output-format', 'stream-json'], + cwd: '/workspace', + env: { PATH: '/bin', OMITTED: undefined }, + signal: new AbortController().signal, + ...overrides, + } +} + +interface FakeRun { + readonly child: FakeChild + readonly query: Query + readonly close: ReturnType + readonly spawnSpecs: SubprocessSpawnSpec[] + readonly options: Array>[0]['options']> + readonly spec: ClaudeCodeRunSpec +} + +function fakeRun( + messages: readonly SDKMessage[] = [success()], + after?: Error, + child = fakeChild(), +): FakeRun { + const close = vi.fn() + const query = queryFrom(messages, after, close) + const spawnSpecs: SubprocessSpawnSpec[] = [] + const options: FakeRun['options'] = [] + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: { ANTHROPIC_API_KEY: 'fake-key' }, + disposeGraceMs: 5, + spawn: (spawnSpec) => { + spawnSpecs.push(spawnSpec) + return child.handle + }, + query: (params) => { + options.push(params.options) + params.options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return query + }, + } + return { child, query, close, spawnSpecs, options, spec } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +describe('task admission and package contracts', () => { + it('preserves text sequences and rejects empty, blank, and non-text tasks', () => { + expect(textTask([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ])).toBe('onetwo') + expect(() => textTask([])).toThrow('only text blocks') + expect(() => textTask([{ type: 'reasoning', text: 'hidden' }])) + .toThrow('only text blocks') + expect(() => textTask([{ type: 'text', text: ' \n ' }])) + .toThrow('must not be empty') + }) + + it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const fiber = await ctx.plugin(claudeCode, {}) + expect(ctx.subagents.getProvider('claude-code')).toMatchObject({ + name: 'claude-code', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['claude-code']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + + for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(ctx.plugin(claudeCode, { disposeGraceMs })) + .rejects.toThrow('disposeGraceMs must be a positive finite number') + } + await ctx.fiber.dispose() + }) + + it('starts through the registered provider with its resolved config and diagnostics', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const child = fakeChild() + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + .mockImplementation(() => child.handle) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + await ctx.plugin(claudeCode, { + env: { + ANTHROPIC_API_KEY: 'provider-fake-key', + CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config', + HOME: '/private/tmp/dsh-claude-code-unit-home', + }, + disposeGraceMs: 29, + }) + + const run = await ctx.subagents.start('claude-code', request()) + child.settle({ exitCode: 9, signal: null }) + child.stdout.end() + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'subagent-claude-code: child run failed (error):', + )) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + cwd: process.cwd(), + graceMs: 29, + })) + expect(spawn.mock.calls[0]?.[0].env).toMatchObject({ + ANTHROPIC_API_KEY: 'provider-fake-key', + }) + await run.dispose() + await ctx.fiber.dispose() + }) + + it('keeps the Loader namespace shape and package-owned empty invariant', async () => { + expect('default' in claudeCode).toBe(false) + expect(claudeCode.name).toBe('subagent-claude-code') + expect(claudeCode.inject).toEqual(['subagents', 'subprocess']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(claudeCode)).toBe(claudeCode) + + const dispose = vi.fn() + const register = vi.fn(( + _packageName: string, + _installer: InvariantInstaller, + ) => dispose) + const ctx = { invariants: { register } } as unknown as Context + await expect(invariant.apply(ctx)).resolves.toBe(dispose) + expect(register).toHaveBeenCalledWith( + '@deepseek-ai/dsh-subagent-claude-code', + expect.any(Function), + ) + const install = register.mock.calls[0]![1] + await install(new Context(), (message) => { throw new Error(message) }) + expect(invariant.name).toBe('subagent-claude-code-invariant') + expect(invariant.inject).toEqual(['invariants']) + }) +}) + +describe('official spawn projection', () => { + it('forwards command, arguments, cwd, environment, and signal exactly', () => { + const signal = new AbortController().signal + const options = sdkSpawnOptions({ + command: '/official/claude', + args: ['--one', 'two'], + cwd: '/parent/workspace', + env: { A: 'one', B: undefined, C: 'three' }, + signal, + }) + expect(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' }) + expect(claudeSpawnSpec(options, 321)).toEqual({ + argv: ['/official/claude', '--one', 'two'], + cwd: '/parent/workspace', + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: 321, + signal, + env: { A: 'one', C: 'three' }, + }) + const missingCwd = sdkSpawnOptions() + delete missingCwd.cwd + expect(() => claudeSpawnSpec( + missingCwd, + 321, + )).toThrow('SDK spawn request omitted its workspace') + expect(() => claudeSpawnSpec( + sdkSpawnOptions({ cwd: '' }), + 321, + )).toThrow('SDK spawn request omitted its workspace') + }) + + it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const process = new ManagedClaudeCodeProcess(child.handle) + expect(process.stdin).toBe(child.stdin) + expect(process.stdout).toBe(child.stdout) + expect(process.killed).toBe(false) + expect(process.exitCode).toBeNull() + expect(process.signalCode).toBeNull() + + const exit = vi.fn() + const once = vi.fn() + const removed = vi.fn() + process.on('exit', exit) + process.once('exit', once) + process.on('exit', removed) + process.off('exit', removed) + expect(process.kill('SIGTERM')).toBe(true) + expect(process.killed).toBe(true) + expect(process.kill('SIGKILL')).toBe(false) + expect(child.terminate).toHaveBeenCalledOnce() + + child.settle({ exitCode: null, signal: 'SIGTERM' }) + await nextTask() + expect(exit).toHaveBeenCalledWith(null, 'SIGTERM') + expect(once).toHaveBeenCalledOnce() + expect(removed).not.toHaveBeenCalled() + expect(process.signalCode).toBe('SIGTERM') + expect(process.kill('SIGTERM')).toBe(false) + }) + + it('emits spawn errors and rejects handles without the required pipes', async () => { + const child = fakeChild() + const process = new ManagedClaudeCodeProcess(child.handle) + const errorListener = vi.fn() + const removed = vi.fn() + process.once('error', errorListener) + process.on('error', removed) + process.off('error', removed) + child.fail(new Error('spawn boom')) + await nextTask() + expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({ + message: 'spawn boom', + })) + expect(removed).not.toHaveBeenCalled() + + const missingStdin = fakeChild({ stdin: undefined }) + Object.defineProperty(missingStdin.handle, 'stdin', { value: undefined }) + expect(() => new ManagedClaudeCodeProcess(missingStdin.handle)) + .toThrow('requires piped stdin and stdout') + const missingStdout = fakeChild({ stdout: undefined }) + Object.defineProperty(missingStdout.handle, 'stdout', { value: undefined }) + expect(() => new ManagedClaudeCodeProcess(missingStdout.handle)) + .toThrow('requires piped stdin and stdout') + }) + + it('exposes a settled direct-child exit code', async () => { + const child = fakeChild() + const process = new ManagedClaudeCodeProcess(child.handle) + child.settle({ exitCode: 7, signal: null }) + await nextTask() + expect(process.exitCode).toBe(7) + expect(process.signalCode).toBeNull() + expect(process.kill('SIGTERM')).toBe(false) + }) +}) + +describe('query options and result mapping', () => { + it('builds the fixed unattended options over the scrubbed environment', () => { + vi.stubEnv('HOST_VISIBLE', 'visible') + vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak') + vi.stubEnv('DSH_INTERNAL', 'must-not-leak') + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + const captured: SubprocessHandle[] = [] + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: { + HOST_VISIBLE: 'overridden', + ANTHROPIC_API_KEY: 'explicit-fake-key', + }, + disposeGraceMs: 17, + spawn, + } + const controller = new AbortController() + const options = claudeQueryOptions(spec, controller, (value) => { + captured.push(value) + }) + + expect(options).toMatchObject({ + abortController: controller, + cwd: '/workspace', + persistSession: false, + disallowedTools: ['AskUserQuestion'], + }) + expect(options.env).toMatchObject({ + HOST_VISIBLE: 'overridden', + ANTHROPIC_API_KEY: 'explicit-fake-key', + }) + expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN') + expect(options.env).not.toHaveProperty('DSH_INTERNAL') + for (const omitted of [ + 'settingSources', + 'canUseTool', + 'onElicitation', + 'onUserDialog', + 'supportedDialogKinds', + ]) { + expect(options).not.toHaveProperty(omitted) + } + + const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess) + expect(captured).toEqual([child.handle]) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + argv: ['/sdk/claude', '--output-format', 'stream-json'], + cwd: '/workspace', + graceMs: 17, + })) + }) + + it('accepts only a non-error success with a non-blank final result', () => { + expect(successfulResult(success('exact final'))).toBe('exact final') + expect(() => successfulResult(success('answer', true))) + .toThrow('marked as an error') + expect(() => successfulResult(success(' \n '))) + .toThrow('contained no answer') + expect(() => successfulResult(failure( + 'error_during_execution', + ['first', 'second'], + ))).toThrow('first; second') + expect(() => successfulResult(failure( + 'error_max_turns', + [], + ))).toThrow('error_max_turns') + }) + + it('consumes the complete stream and keeps the latest strict success', async () => { + const outputs: ContentBlock[][] = [] + const query = queryFrom([ + { type: 'system', subtype: 'init' } as SDKMessage, + success('first'), + success('last'), + ]) + await expect(consumeClaudeQuery(query, (output) => { + outputs.push(output) + })).resolves.toEqual({ + output: [{ type: 'text', text: 'last' }], + stopReason: 'completed', + }) + expect(outputs).toEqual([ + [{ type: 'text', text: 'first' }], + [{ type: 'text', text: 'last' }], + ]) + await expect(consumeClaudeQuery( + queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), + () => {}, + )).rejects.toThrow('ended without a result') + }) +}) + +describe('run publication, cancellation, and settlement', () => { + it('publishes only after Query and managed child exist, then disposes once', async () => { + const fixture = fakeRun([success('exact answer')]) + const run = await startClaudeCodeRun( + request([ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' }, + ]), + fixture.spec, + ) + expect(fixture.options).toHaveLength(1) + expect(fixture.spawnSpecs).toHaveLength(1) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'exact answer' }], + stopReason: 'completed', + }) + const first = run.dispose() + const second = run.dispose() + expect(second).toBe(first) + await first + expect(fixture.close).toHaveBeenCalledOnce() + expect(fixture.child.terminate).toHaveBeenCalledOnce() + }) + + it('flattens every SDK error result without inventing shared stop reasons', async () => { + const subtypes: ErrorSubtype[] = [ + 'error_during_execution', + 'error_max_turns', + 'error_max_budget_usd', + 'error_max_structured_output_retries', + ] + for (const subtype of subtypes) { + const fixture = fakeRun([failure(subtype)]) + const onError = vi.fn() + const run = await startClaudeCodeRun( + request(), + { ...fixture.spec, onError }, + ) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + 'error', + ) + await run.dispose() + } + }) + + it('preserves candidate output when iteration fails after a result', async () => { + const fixture = fakeRun( + [success('partial final')], + new Error('iterator boom'), + ) + const run = await startClaudeCodeRun(request(), fixture.spec) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial final' }], + stopReason: 'error', + }) + await run.dispose() + }) + + it('maps invalid success and missing result to error', async () => { + for (const messages of [ + [success('answer', true)], + [success('')], + [{ type: 'system', subtype: 'init' } as SDKMessage], + ]) { + const fixture = fakeRun(messages) + const run = await startClaudeCodeRun(request(), fixture.spec) + await expect(run.result).resolves.toMatchObject({ + stopReason: 'error', + }) + await run.dispose() + } + }) + + it('gives local cancellation precedence and isolates overlapping controllers', async () => { + const firstChild = fakeChild() + const secondChild = fakeChild() + const children = [firstChild, secondChild] + const controllers: AbortController[] = [] + let index = 0 + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: {}, + disposeGraceMs: 5, + spawn: () => children[index++]!.handle, + query: ({ prompt, options }) => { + controllers.push(options.abortController!) + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return prompt === 'wait' + ? waitingQuery(options.abortController!.signal) + : queryFrom([success('second answer')]) + }, + } + const firstAbort = new AbortController() + const first = await startClaudeCodeRun( + request([{ type: 'text', text: 'wait' }], firstAbort.signal), + spec, + ) + const second = await startClaudeCodeRun( + request([{ type: 'text', text: 'finish' }]), + spec, + ) + expect(controllers).toHaveLength(2) + expect(controllers[0]).not.toBe(controllers[1]) + firstAbort.abort(new Error('parent cancelled')) + await expect(first.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await expect(second.result).resolves.toEqual({ + output: [{ type: 'text', text: 'second answer' }], + stopReason: 'completed', + }) + expect(controllers[1]!.signal.aborted).toBe(false) + await Promise.all([first.dispose(), second.dispose()]) + }) + + it('rejects pre-abort and every incomplete startup transaction', async () => { + const preAborted = new AbortController() + preAborted.abort() + const unused = fakeRun() + await expect(startClaudeCodeRun( + request(undefined, preAborted.signal), + unused.spec, + )).rejects.toThrow('aborted before SDK startup') + expect(unused.options).toEqual([]) + + const noChildClose = vi.fn() + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + query: () => queryFrom([], undefined, noChildClose), + })).rejects.toThrow('did not publish a controllable') + expect(noChildClose).toHaveBeenCalledOnce() + + const closeFailure = vi.fn(() => { throw new Error('close boom') }) + const noChild = startClaudeCodeRun(request(), { + ...unused.spec, + query: () => queryFrom([], undefined, closeFailure), + }) + await expect(noChild).rejects.toBeInstanceOf(AggregateError) + + const startupAbort = new AbortController() + const abortedChild = fakeChild() + const abortedClose = vi.fn() + const abortedDuringStartup = startClaudeCodeRun( + request(undefined, startupAbort.signal), + { + ...unused.spec, + spawn: () => abortedChild.handle, + query: ({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + startupAbort.abort(new Error('startup cancelled')) + return queryFrom([], undefined, abortedClose) + }, + }, + ) + await expect(abortedDuringStartup) + .rejects.toThrow('aborted before SDK startup') + expect(abortedClose).toHaveBeenCalledOnce() + expect(abortedChild.terminate).toHaveBeenCalledOnce() + + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + query: () => { + throw new Error('query failed before resource creation') + }, + })).rejects.toThrow('query failed before resource creation') + + const spawned = fakeChild() + const spawnSpecs: SubprocessSpawnSpec[] = [] + let factoryController: AbortController | undefined + const factoryFailure = startClaudeCodeRun(request(), { + ...unused.spec, + spawn: (spawnSpec) => { + spawnSpecs.push(spawnSpec) + return spawned.handle + }, + query: ({ options }) => { + factoryController = options.abortController + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw new Error('query construction failed') + }, + }) + await expect(factoryFailure).rejects.toThrow('query construction failed') + expect(spawnSpecs).toHaveLength(1) + expect(factoryController?.signal.aborted).toBe(true) + expect(spawned.terminate).toHaveBeenCalledOnce() + + const failedSpawn = fakeChild({ + pid: -1, + doneError: new Error('spawn failed'), + }) + const failed = fakeRun([], undefined, failedSpawn) + await expect(startClaudeCodeRun(request(), failed.spec)) + .rejects.toBeInstanceOf(AggregateError) + expect(failed.close).toHaveBeenCalledOnce() + }) +}) + +describe('bounded query and process disposal', () => { + it('closes the query, terminates the tree, and waits for direct-child outcome', async () => { + const child = fakeChild() + const close = vi.fn() + await disposeClaudeCodeChild({ close }, child.handle, 5) + expect(close).toHaveBeenCalledOnce() + expect(child.terminate).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledOnce() + await expect(child.handle.done).resolves.toEqual({ + exitCode: 0, + signal: null, + }) + }) + + it('accepts fractional and larger-than-Node grace windows', async () => { + for (const graceMs of [0.25, Number.MAX_VALUE]) { + const child = fakeChild() + await expect(disposeClaudeCodeChild( + { close: vi.fn() }, + child.handle, + graceMs, + )).resolves.toBeUndefined() + const signal = child.waitForExit.mock.calls[0]?.[0] + expect(signal?.aborted).toBe(false) + } + }) + + it('chains a doubled grace window beyond one Node timer segment', async () => { + vi.useFakeTimers() + try { + const child = fakeChild({ exitOnTerminate: false }) + const disposal = disposeClaudeCodeChild( + { close: vi.fn() }, + child.handle, + 1_073_741_823.75, + ) + const rejected = expect(disposal) + .rejects.toThrow('did not exit within its dispose window') + await vi.advanceTimersByTimeAsync(2_147_483_647) + await vi.advanceTimersByTimeAsync(1) + await rejected + } finally { + vi.useRealTimers() + } + }) + + it('does not turn a missed tree-exit bound into an unbounded done wait', async () => { + const child = fakeChild({ + exitOnTerminate: false, + waitForExitResult: false, + }) + await expect(disposeClaudeCodeChild( + { close: vi.fn() }, + child.handle, + 5, + )).rejects.toThrow('did not exit within its dispose window') + child.fail(new Error('late direct-child failure')) + await nextTask() + }) + + it('reports wait, close, and direct-child failures without skipping cleanup', async () => { + const waitFailure = fakeChild({ + exitOnTerminate: false, + waitForExitError: new Error('wait boom'), + }) + const closeFailure = vi.fn(() => { throw new Error('close boom') }) + await expect(disposeClaudeCodeChild( + { close: closeFailure }, + waitFailure.handle, + 5, + )).rejects.toBeInstanceOf(AggregateError) + expect(waitFailure.terminate).toHaveBeenCalledOnce() + + const doneFailure = fakeChild({ + pid: -1, + doneError: new Error('spawn boom'), + }) + await expect(disposeClaudeCodeChild( + { close: vi.fn() }, + doneFailure.handle, + 5, + )).rejects.toThrow('spawn boom') + + const both = fakeChild({ + pid: -1, + doneError: new Error('spawn boom'), + }) + await expect(disposeClaudeCodeChild( + { close: () => { throw new Error('close boom') } }, + both.handle, + 5, + )).rejects.toBeInstanceOf(AggregateError) + }) +}) diff --git a/packages/subagent/subagent-claude-code/tsconfig.json b/packages/subagent/subagent-claude-code/tsconfig.json new file mode 100644 index 0000000000..72e5f73fec --- /dev/null +++ b/packages/subagent/subagent-claude-code/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/types/.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 98763ddb6e..3c3632c160 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/README.md -README.md: 4682b06ae105a0ae70ea7e78a80776ac18d817e7 -README.zh.md: c39afb26d8c6baf4774ae3b4a8f8151529a29e15 +README.md: a388f5a57fd32768dc9b66e3637ff53bf6479149 +README.zh.md: 48e5694e82ee0269661fbb5ede75cf995cbc00aa diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4682b06ae1..a388f5a57f 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -15,6 +15,7 @@ The family separates the stable interface from implementations and model-facing | `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | | `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). | +| `@deepseek-ai/dsh-subagent-claude-code` | Fresh official Claude Agent SDK query with a real managed Claude Code CLI child (one-shot). | | `@deepseek-ai/dsh-subagent-dsh-sdk` | Fresh out-of-process harness child driven through the TypeScript SDK client (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | @@ -64,7 +65,7 @@ The seam owns the versioned `subagent/descriptor` session event vocabulary (`src The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level. -`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority. +`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority. ## One-shot ownership and lifecycle diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index c39afb26d8..48e5694e82 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -15,6 +15,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | | `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 | +| `@deepseek-ai/dsh-subagent-claude-code` | 通过官方 Claude Agent SDK 启动的全新 query,带有一个真实且受管的 Claude Code CLI 子进程(一次性)。 | | `@deepseek-ai/dsh-subagent-dsh-sdk` | 通过 TypeScript SDK 客户端驱动的全新进程外 harness 子 agent(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | @@ -64,7 +65,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。 -`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 +`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 ## 一次性所有权与生命周期 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 352a8a2abf..8bb0991739 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:* + version: link:../packages/subagent/subagent-claude-code '@deepseek-ai/dsh-subagent-codex': specifier: workspace:* version: link:../packages/subagent/subagent-codex @@ -4951,6 +4954,46 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/subagent-claude-code: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.220 + version: 0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + '@anthropic-ai/sdk': + specifier: 0.93.0 + version: 0.93.0(zod@4.4.3) + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subagent/subagent-codex: dependencies: schemastery: @@ -6824,6 +6867,58 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220': + resolution: {integrity: sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220': + resolution: {integrity: sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220': + resolution: {integrity: sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220': + resolution: {integrity: sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220': + resolution: {integrity: sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220': + resolution: {integrity: sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220': + resolution: {integrity: sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220': + resolution: {integrity: sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.220': + resolution: {integrity: sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -6833,6 +6928,15 @@ packages: zod: optional: true + '@anthropic-ai/sdk@0.93.0': + resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -12234,12 +12338,57 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.220 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.220 + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: zod: 4.4.3 + '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 7c3c8261fe..d579059f27 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], - note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'], + note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, Claude Code, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', @@ -416,7 +416,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider and continuation service', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index f31cca6879..e6e199c21a 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -2,7 +2,20 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps, virtualManifest } from './gen-third-party-notices.ts' +import { + CLAUDE_AGENT_SDK_PACKAGE, + claudeDistributionFromManifest, + collectPythonDependencies, + isOwnerAuthorizedRuntime, + isPermissive, + type Manifest, + manifestPatterns, + parsePyprojectRequirements, + parseVendoredRows, + render, + tierExternalDeps, + virtualManifest, +} from './gen-third-party-notices.ts' const root = resolve(import.meta.dirname, '..') @@ -223,7 +236,14 @@ describe('collectPythonDependencies', () => { describe('isPermissive', () => { it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => { expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true) - expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false) + expect([ + 'LGPL-3.0-only', + 'MPL-2.0', + 'GPL-3.0-or-later', + 'SEE LICENSE IN LICENSE', + 'SEE LICENSE IN README.md', + 'SEE LICENSE IN LICENSE.md', + ].some(isPermissive)).toBe(false) }) it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => { @@ -245,6 +265,66 @@ describe('isPermissive', () => { }) }) +describe('official Claude distribution authorization', () => { + it('authorizes only the direct SDK identity without relabeling its license', () => { + expect(isOwnerAuthorizedRuntime(CLAUDE_AGENT_SDK_PACKAGE)).toBe(true) + expect(isOwnerAuthorizedRuntime(`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`)) + .toBe(false) + expect(isOwnerAuthorizedRuntime('@anthropic-ai/unrelated')).toBe(false) + expect(isPermissive('SEE LICENSE IN README.md')).toBe(false) + }) + + it('derives version-independent platform payloads from the official SDK manifest', () => { + expect(claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '9.8.7', + license: 'future declared terms', + claudeCodeVersion: '6.5.4', + optionalDependencies: { + [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '9.8.7', + [`${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`]: '9.8.7', + }, + })).toEqual({ + sdkVersion: '9.8.7', + claudeCodeVersion: '6.5.4', + payloads: [ + { + name: `${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`, + version: '9.8.7', + }, + { + name: `${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`, + version: '9.8.7', + }, + ], + }) + }) + + it('rejects a wrong SDK identity, missing payloads, and unrelated optionals', () => { + expect(() => claudeDistributionFromManifest({ + name: '@anthropic-ai/unrelated', + version: '1.0.0', + claudeCodeVersion: '1.0.0', + optionalDependencies: { + [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '1.0.0', + }, + })).toThrow(`expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest`) + expect(() => claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '1.0.0', + claudeCodeVersion: '1.0.0', + })).toThrow('declares no optional platform payloads') + expect(() => claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '1.0.0', + claudeCodeVersion: '1.0.0', + optionalDependencies: { + '@anthropic-ai/unrelated': '1.0.0', + }, + })).toThrow('outside its authorized platform-payload identity') + }) +}) + describe('manifestPatterns', () => { it('derives globs from the declared members, so a new member area is read', () => { expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([ diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 6b790829d5..3e2e0917f2 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -49,6 +49,21 @@ const FIRST_PARTY = new Set([ 'node-addon-landlock-run-linux-x64', ]) +/** Official SDK identity covered by the project's narrow owner authorization. */ +export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' +const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-` +const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md' + +/** + * Whether a non-permissive runtime declaration has an identity-scoped owner + * authorization. This does not reclassify its terms as permissive. + * @param name - exact npm package identity. + * @returns true only for the official Claude Agent SDK package. + */ +export function isOwnerAuthorizedRuntime(name: string): boolean { + return name === CLAUDE_AGENT_SDK_PACKAGE +} + /** * Metadata overrides where the installed manifest is wrong or unreachable. * Each entry documents why the store cannot answer. @@ -92,6 +107,7 @@ const BUILD_TIME_TOOLS = [ /** The `package.json` fields this generator reads. */ export interface Manifest { name?: string + version?: string private?: boolean license?: string dependencies?: Record @@ -164,7 +180,74 @@ function loadWorkspaceManifests(): { manifests: Map; names: Se return { manifests, names } } -type VirtualManifest = Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string } +type VirtualManifest = Manifest & { + claudeCodeVersion?: string + license?: string + repository?: string | { url?: string } + homepage?: string +} + +/** One platform payload declared by the official Claude Agent SDK. */ +export interface ClaudePlatformPayload { + readonly name: string + readonly version: string +} + +/** Current SDK and CLI distribution facts derived from the installed SDK manifest. */ +export interface ClaudeDistribution { + readonly sdkVersion: string + readonly claudeCodeVersion: string + readonly payloads: ClaudePlatformPayload[] +} + +function requiredManifestString( + value: string | undefined, + field: string, +): string { + if (value === undefined || value.length === 0) { + throw new Error(`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} has no ${field}.`) + } + return value +} + +/** + * Derive the official platform payload set without a version or platform + * allowlist. Only identities in the SDK's own package namespace are covered. + * @param manifest - installed official SDK manifest. + * @returns current SDK, CLI, and optional platform payload facts. + */ +export function claudeDistributionFromManifest( + manifest: VirtualManifest, +): ClaudeDistribution { + if (manifest.name !== CLAUDE_AGENT_SDK_PACKAGE) { + throw new Error( + `gen-third-party-notices: expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest, got ${JSON.stringify(manifest.name)}.`, + ) + } + const sdkVersion = requiredManifestString(manifest.version, 'version') + const claudeCodeVersion = requiredManifestString( + manifest.claudeCodeVersion, + 'claudeCodeVersion', + ) + const entries = Object.entries(manifest.optionalDependencies ?? {}) + if (entries.length === 0) { + throw new Error( + `gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} declares no optional platform payloads.`, + ) + } + const payloads = entries.map(([name, version]) => { + if (!name.startsWith(CLAUDE_PLATFORM_PACKAGE_PREFIX)) { + throw new Error( + `gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} optional dependency ${name} is outside its authorized platform-payload identity.`, + ) + } + return { + name, + version: requiredManifestString(version, `${name} optional dependency version`), + } + }).sort((left, right) => left.name.localeCompare(right.name)) + return { sdkVersion, claudeCodeVersion, payloads } +} /** * Resolve one package's manifest inside a pnpm virtual store. The prefix scan @@ -193,9 +276,8 @@ export function virtualManifest(virtual: string, name: string): VirtualManifest return undefined } -/** License and repository URL for an installed external package, from the pnpm store. */ -function installedMetadata(name: string): { license: string; repo: string } { - const override = OVERRIDES[name] +/** Resolve one installed external package manifest from either pnpm store. */ +function installedManifest(name: string): VirtualManifest | undefined { let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined // The nested Landlock workspace installs into its own store, so a package // only that workspace depends on is unreachable from the root one. @@ -210,6 +292,13 @@ function installedMetadata(name: string): { license: string; repo: string } { manifest = virtualManifest(virtual, name) if (manifest !== undefined) break } + return manifest +} + +/** License and repository URL for an installed external package, from the pnpm store. */ +function installedMetadata(name: string): { license: string; repo: string } { + const override = OVERRIDES[name] + const manifest = installedManifest(name) const license = override?.license ?? manifest?.license const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage const repo = override?.repo ?? normalizeRepo(rawRepo) @@ -219,6 +308,37 @@ function installedMetadata(name: string): { license: string; repo: string } { return { license, repo } } +function collectClaudeDistribution(): ClaudeDistribution { + const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE) + if (manifest === undefined) { + throw new Error( + `gen-third-party-notices: cannot resolve ${CLAUDE_AGENT_SDK_PACKAGE}; run \`pnpm install\`.`, + ) + } + const distribution = claudeDistributionFromManifest(manifest) + let installedPayloads = 0 + for (const payload of distribution.payloads) { + const installed = installedManifest(payload.name) + if (installed === undefined) continue + installedPayloads += 1 + if ( + installed.name !== payload.name + || installed.version !== payload.version + || installed.license !== CLAUDE_PLATFORM_DECLARED_LICENSE + ) { + throw new Error( + `gen-third-party-notices: installed ${payload.name} does not match its SDK-declared version and ${CLAUDE_PLATFORM_DECLARED_LICENSE} license field.`, + ) + } + } + if (installedPayloads === 0) { + throw new Error( + 'gen-third-party-notices: no SDK-declared Claude platform payload is installed; install optional dependencies before regenerating.', + ) + } + return distribution +} + /** Normalize a manifest repository/homepage value to a browsable https URL. */ function normalizeRepo(raw: string | undefined): string | undefined { if (raw === undefined || raw === '') return undefined @@ -519,6 +639,26 @@ function renderNpmTable(deps: ExternalDep[]): string { return lines.join('\n') } +function renderClaudeDistribution( + distribution: ClaudeDistribution | undefined, +): string { + if (distribution === undefined) return '' + const rows = distribution.payloads.map(payload => + `| [\`${payload.name}\`](https://www.npmjs.com/package/${payload.name}) | ${payload.version} | ${CLAUDE_PLATFORM_DECLARED_LICENSE} |`, + ) + return ` +## Official Claude Code platform payloads + +The project owner authorizes distribution of every version of the official \`${CLAUDE_AGENT_SDK_PACKAGE}\` package and the official Claude Code CLI/platform payloads that each version declares through \`optionalDependencies\`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. + +The installed SDK ${distribution.sdkVersion} declares the following optional platform packages. Each carries the official Claude Code ${distribution.claudeCodeVersion} executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. + +| Optional platform package | Version | Declared license | +| --- | --- | --- | +${rows.join('\n')} +` +} + /** * Render the complete notices document. * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold. @@ -531,11 +671,19 @@ export function render(): string { const vendored = collectVendored() const python = collectPython() const patched = collectPatched() + const claudeDistribution = runtimeDeps.some( + dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE, + ) + ? collectClaudeDistribution() + : undefined const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) // A copyleft license reaching a shipped surface is a distribution decision, // not a rendering detail; the notices cannot quietly absorb it. - const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license)) + const nonPermissiveRuntime = runtimeDeps.filter(dep => + !isPermissive(dep.license) + && !isOwnerAuthorizedRuntime(dep.name), + ) if (nonPermissiveRuntime.length > 0) { throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`) } @@ -548,7 +696,7 @@ export function render(): string { DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml). @@ -569,6 +717,7 @@ ${renderNpmTable(runtimeDeps)} pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: ${patchedLines.join('\n')} +${renderClaudeDistribution(claudeDistribution)} ## Development-only npm dependencies diff --git a/tsconfig.host.json b/tsconfig.host.json index 06af7e5871..8d0dd3a2e7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -194,6 +194,7 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/subagent/subagent-claude-code" }, { "path": "./packages/subagent/subagent-codex" }, { "path": "./packages/subagent/subagent-dsh-sdk" }, { "path": "./packages/tasks/tasks" }, diff --git a/vitest.config.ts b/vitest.config.ts index eac84d8d20..0940df235e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -54,6 +54,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subagent/subagent-claude-code/tests/real-product.spec.ts', 'packages/subagent/subagent-codex/tests/real-product.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', From ec297c0ca0b419b25815423aadc251715eb22586 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 4 Aug 2026 21:02:03 +0800 Subject: [PATCH 022/179] feat(web): add fuzzy slash command discovery --- ...eb-slash-command-fuzzy-discovery.i18n.yaml | 6 ++ ...08-04-web-slash-command-fuzzy-discovery.md | 27 +++++++ ...04-web-slash-command-fuzzy-discovery.zh.md | 27 +++++++ apps/web/tests/lifecycle-chrome.e2e.ts | 9 ++- .../command-menu-fuzzy.expected.md | 3 + packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 2 + packages/client/ui-command/README.zh.md | 2 + .../client/ui-command/src/client/service.ts | 79 +++++++++++++++++-- .../client/ui-command/tests/service.spec.ts | 26 +++++- 10 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml new file mode 100644 index 0000000000..900bc3562d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.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-04-web-slash-command-fuzzy-discovery.md +2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238 +2026-08-04-web-slash-command-fuzzy-discovery.zh.md: d3efdc23351a1b50853ee76fb731aee046004750 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md new file mode 100644 index 0000000000..8d7fe88f8d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md @@ -0,0 +1,27 @@ +# Agent Note: Web slash-command fuzzy discovery + +Status: implemented + +English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md) + +## Problem + +The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. + +## Decision + +The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. + +The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token. + +## Alternatives considered + +**Keep prefix-only matching.** Rejected because it preserves the recall failure that motivates the feature; `/cpt` cannot discover `/compact`. + +**Match unordered characters or descriptions.** Rejected because unordered matches are difficult to predict, while description matches can surface commands whose visible names do not explain why they ranked. + +**Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product. + +## Consequences + +Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md new file mode 100644 index 0000000000..d3efdc2335 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Web 斜杠命令模糊发现 + +Status: implemented + +[English](2026-08-04-web-slash-command-fuzzy-discovery.md) | 中文 + +## Problem + +Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 + +## Decision + +`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 + +评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而 space 与 Enter 裁决继续要求命令 token 精确匹配。 + +## Alternatives considered + +**保留仅前缀匹配。** 否决,因为本功能要解决的用户无法准确回忆前缀的问题依然存在:`/cpt` 无法发现 `/compact`。 + +**匹配无序字符或描述。** 否决,因为无序匹配难以预测,而描述匹配可能展示命令,但命令的可见名称无法解释其排名。 + +**使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。 + +## Consequences + +用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包(package)测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index b757af08d7..3883b9d6e0 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') +const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. @@ -83,6 +84,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(Math.abs( launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, )).toBeLessThan(1) + await input.fill('/cpt') + await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([ + 'compactCompact older conversation history', + ]) + const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE) await input.fill('') await expect.poll(() => menu.count()).toBe(0) }) @@ -254,7 +261,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md new file mode 100644 index 0000000000..13d915959d --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu-fuzzy.expected.md @@ -0,0 +1,3 @@ +- listbox "Trigger suggestions": + - text: Commands + - option "compact Compact older conversation history" [selected] diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index fb3743a8a7..fc3b051b58 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/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-command/README.md -README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4 -README.zh.md: ed607de783e833eed94fba09bc20c74375711a4f +README.md: 37df56c815c2dfd1d54a9dc0be4e28363cc8b66c +README.zh.md: c90463dcac0095231327fa0fb7de1875457b73bc diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index c892f2f244..37df56c815 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -8,6 +8,8 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). + `PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`. The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index ed607de783..c90463dcac 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -8,6 +8,8 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 + `PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。 `/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的契约类型;壳组件本身是 overlay 注册的内部实现。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 9784c56ced..33b42f6b51 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -2,9 +2,10 @@ * CommandService (`ctx.command`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then query/position filtering; - * a host/contribution name collision fails loud. Every execute addresses the - * session's agent by sessionId — sessions are always agent-backed. + * catalog with contributions by availability, then fuzzy query/position + * filtering; a host/contribution name collision fails loud. Every execute + * addresses the session's agent by sessionId — sessions are always + * agent-backed. */ import { Service } from 'cordis' import type { Context } from 'cordis' @@ -27,6 +28,69 @@ interface LiveState { readonly popups: Map> } +/** One fuzzy match with its stable source position. */ +interface RankedCandidate { + readonly candidate: SlashCandidate + readonly index: number + readonly prefix: boolean + readonly score: number +} + +/** Extra weight for command-name starts and separator boundaries. */ +function boundaryBonus(name: string, index: number): number { + return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 +} + +/** + * Score the strongest ordered-subsequence alignment in O(name × query). + * Boundary and adjacent matches earn weight; skipped and leading characters + * cost weight. + */ +function fuzzyScore(name: string, query: string): number | undefined { + if (query === '') return 0 + if (query.length > name.length) return undefined + const noMatch = Number.NEGATIVE_INFINITY + let previous = Array(name.length).fill(noMatch) + for (let index = 0; index < name.length; index++) { + if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index + } + for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { + const current = Array(name.length).fill(noMatch) + let bestGapped = noMatch + for (let index = 0; index < name.length; index++) { + const gappedIndex = index - 2 + if (gappedIndex >= 0) { + const prior = previous[gappedIndex] ?? noMatch + if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex) + } + if (name.charAt(index) !== query.charAt(queryIndex)) continue + const bonus = 1 + boundaryBonus(name, index) + const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch + if (adjacent !== noMatch) current[index] = adjacent + bonus + 4 + if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index) + } + previous = current + } + let best = noMatch + for (const score of previous) best = Math.max(best, score) + return best === noMatch ? undefined : best +} + +/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */ +function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string): readonly SlashCandidate[] { + const query = rawQuery.toLowerCase() + if (query === '') return candidates + const ranked: RankedCandidate[] = [] + candidates.forEach((candidate, index) => { + const name = candidate.name.toLowerCase() + const score = fuzzyScore(name, query) + if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score }) + }) + ranked.sort((left, right) => + Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) + return ranked.map(match => match.candidate) +} + /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandService extends Service implements CommandServiceContract { static inject = ['slash', 'sessions', 'connection'] @@ -147,7 +211,7 @@ export class CommandService extends Service implements CommandServiceContract { } } - /** Menu candidates: host catalog + contribution availability, then query/position filtering. */ + /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: SlashCandidate[] = [] @@ -163,9 +227,10 @@ export class CommandService extends Service implements CommandServiceContract { } rows.push({ name: contribution.name, description: contribution.description }) } - return rows - .filter(c => c.name.startsWith(req.query)) - .filter(c => req.position === 'leading' || c.hint === undefined) + return fuzzyCandidates( + rows.filter(c => req.position === 'leading' || c.hint === undefined), + req.query, + ) } /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 08fda13a6f..bd6d72c916 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -164,13 +164,33 @@ describe('candidates', () => { expect(b.listCalls).toEqual([]) }) - it('pulls the session catalog; prefix filter and hint mapping apply', async () => { + it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => { const { source, listCalls } = await bench() const list = await source.candidates(proj('s1'), req('g')) expect(listCalls).toEqual([{ sessionId: sid('s1') }]) expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) }) + it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => { + const commands: CommandDescriptor[] = [ + { name: 'q-xylophone', description: '' }, + { name: 'qx-long', description: '' }, + { name: 'fabulous', description: '' }, + { name: 'foo-bar', description: '' }, + { name: 'zuv', description: '' }, + { name: 'zu1v', description: '' }, + { name: 'yu1v', description: '' }, + { name: 'zu12v', description: '' }, + ] + const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone']) + await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous']) + await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + await expect(names('zzz')).resolves.toEqual([]) + await expect(names('query-longer-than-every-name')).resolves.toEqual([]) + }) + it('catalogs are per session: another session pulls its own key', async () => { const { source, listCalls } = await bench() const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) @@ -195,10 +215,10 @@ describe('candidates', () => { expect(s2Names).not.toContain('theme') }) - it('contribution rows ride the same query prefix filter', async () => { + it('contribution rows ride the same fuzzy query filter', async () => { const { command, source } = await bench() command.register(themeContribution()) - const names = (await source.candidates(proj('s1'), req('th'))).map(c => c.name) + const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name) expect(names).toEqual(['theme']) }) From d78090267994897063b4faa88a8b104d6b8e2ebc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 21:25:48 +0800 Subject: [PATCH 023/179] refactor(subagent): share provider dispose window --- .../subagent/subagent-claude-code/src/run.ts | 42 +----------------- packages/subagent/subagent-codex/src/run.ts | 42 +----------------- .../subagent/subagent/src/out-of-process.ts | 43 +++++++++++++++++++ 3 files changed, 45 insertions(+), 82 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index a65f6f5497..dbb2af3593 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -18,6 +18,7 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { + doubledGraceWindow, settleRunResult, subprocessRunHandle, type SubagentResult, @@ -38,47 +39,6 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Bound final exit observation at twice a positive finite grace without - * narrowing the public config to Node's single-timer integer range. - */ -function doubledGraceWindow(graceMs: number): { - readonly signal: AbortSignal - readonly cancel: () => void -} { - const whole = Math.floor(graceMs) - let remaining = BigInt(whole) * 2n - + BigInt(Math.ceil((graceMs - whole) * 2)) - const controller = new AbortController() - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - controller.abort() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - signal: controller.signal, - cancel: () => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - type QueryFactory = (params: { prompt: string options: Options diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 811f7c8f98..b07bf9dc6b 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -11,6 +11,7 @@ import { randomUUID } from 'node:crypto' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { + doubledGraceWindow, settleRunResult, subprocessRunHandle, type SubagentResult, @@ -24,47 +25,6 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Bound final exit observation at twice a positive finite grace without - * narrowing the public config to Node's single-timer integer range. - */ -function doubledGraceWindow(graceMs: number): { - readonly signal: AbortSignal - readonly cancel: () => void -} { - const whole = Math.floor(graceMs) - let remaining = BigInt(whole) * 2n - + BigInt(Math.ceil((graceMs - whole) * 2)) - const controller = new AbortController() - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - controller.abort() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - signal: controller.signal, - cancel: () => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index fc78fb28fa..86b0772283 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -42,6 +42,49 @@ export function assertPositiveFinite(prefix: string, name: string, value: number } } +/** Largest delay Node schedules without collapsing it to one millisecond. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647n + +/** + * Bound final exit observation at twice a positive finite grace without + * narrowing public provider config to Node's single-timer integer range. + * @param graceMs - the already validated positive finite termination grace. + * @returns a cancellable abort signal for the doubled observation window. + */ +export function doubledGraceWindow(graceMs: number): { + readonly signal: AbortSignal + readonly cancel: () => void +} { + const whole = Math.floor(graceMs) + let remaining = BigInt(whole) * 2n + + BigInt(Math.ceil((graceMs - whole) * 2)) + const controller = new AbortController() + let timer: ReturnType | undefined + const arm = (): void => { + const chunk = remaining > MAX_TIMER_DELAY_MS + ? MAX_TIMER_DELAY_MS + : remaining + remaining -= chunk + timer = setTimeout(() => { + timer = undefined + if (remaining === 0n) { + controller.abort() + } else { + arm() + } + }, Number(chunk)) + } + arm() + return { + signal: controller.signal, + cancel: () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + }, + } +} + /** * Whether `path` names an existing directory the harness can ENTER. The * search-permission probe matters: `statSync().isDirectory()` is true for a From 32f829c4e6a3004268afd26dce8684e3555d83d7 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 22:18:35 +0800 Subject: [PATCH 024/179] fix(subagent): complete product provider lifecycle --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 8 +- .../2026-06-21-subagent-capability-seam.zh.md | 8 +- .../2026-06-22-acp-subagent-backend.i18n.yaml | 4 +- .../2026-06-22-acp-subagent-backend.md | 4 +- .../2026-06-22-acp-subagent-backend.zh.md | 4 +- docs/module-graph.md | 7 + .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent/subagent-claude-code/src/run.ts | 69 ++---- .../tests/subagent-claude-code.spec.ts | 201 ++++++++++-------- .../subagent-claude-code/tsconfig.json | 6 + .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 2 +- packages/subagent/subagent-codex/README.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 29 +-- .../tests/subagent-codex.spec.ts | 79 ++----- .../subagent/subagent/src/out-of-process.ts | 70 ++---- scripts/run-gates.spec.ts | 6 + scripts/run-gates.ts | 2 + 21 files changed, 216 insertions(+), 301 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index f8b82ebfbf..80508d16b4 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 5b9b018df151f0d734b54cfdd4dacfd09058f7d7 -2026-06-21-subagent-capability-seam.zh.md: 49571288e35abb1369c16abd5c77a81dd3212a12 +2026-06-21-subagent-capability-seam.md: 35fe7b7aaf02d9d55012e3285b3f5a58bc76cde8 +2026-06-21-subagent-capability-seam.zh.md: 221335859cec104a55136201e4923d783d616e86 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 5b9b018df1..35fe7b7aaf 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-06-21-subagent-capability-seam.zh.md) -> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its Agent Note](2026-06-22-acp-subagent-backend.md)). +> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process ACP, Codex, and Claude Code backends ([ACP Agent Note](2026-06-22-acp-subagent-backend.md), [product-provider Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md)). ## Problem @@ -14,8 +14,8 @@ The distinctive requirement — the one that shapes the whole design — is that - **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); -- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); -- later: **A2A** and the **Claude Code Agent SDK** — the same out-of-process "start a child, prompt it, settle, cancel" shape; the Claude sibling remains in the product-provider proposal. +- **Codex app-server and Claude Code Agent SDK** — current one-shot siblings that apply the same named-provider seam to official product processes ([product-provider Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md)); +- later: **A2A** using the same out-of-process "start a child, prompt it, settle, cancel" shape. ## Alternatives considered @@ -35,6 +35,8 @@ A new package group `packages/subagent/`: | `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` | | `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log | | `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process | +| `@deepseek-ai/dsh-subagent-codex` | implementation: a one-shot official Codex app-server process | +| `@deepseek-ai/dsh-subagent-claude-code` | implementation: a one-shot official Claude Code process through the Agent SDK | | `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` | ### The primitive: async `start → SubagentRun` diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 49571288e3..221335859c 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-21-subagent-capability-seam.md) | 中文 -> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`([其 Agent Note](2026-06-22-acp-subagent-backend.md))。 +> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外的 ACP、Codex 与 Claude Code 后端([ACP Agent Note](2026-06-22-acp-subagent-backend.md)、[产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md))。 ## 问题 @@ -14,8 +14,8 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智 - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); -- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)); -- 后续:**A2A** 与 **Claude Code Agent SDK**——两者采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」;Claude 兄弟提供方仍在产品提供方提案中。 +- **Codex app-server 与 Claude Code Agent SDK**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md)); +- 后续:**A2A**,采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」。 ## 曾考虑的替代方案 @@ -35,6 +35,8 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 | `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent | | `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent | | `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 | +| `@deepseek-ai/dsh-subagent-codex` | 实现:一次性官方 Codex app-server 进程 | +| `@deepseek-ai/dsh-subagent-claude-code` | 实现:通过 Agent SDK 运行的一次性官方 Claude Code 进程 | | `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 | ### 原语:异步 `start → SubagentRun` diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 54ba7268df..58325b9b0e 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md -2026-06-22-acp-subagent-backend.md: d839ab6f75d8a518c9bc850894d1c3c5ffdbed92 -2026-06-22-acp-subagent-backend.zh.md: e9027e282bf351890643e0545b01fe00287375a6 +2026-06-22-acp-subagent-backend.md: c994ebfa69649bb9e79d3aa389a0e13c178131c7 +2026-06-22-acp-subagent-backend.zh.md: e3c651f752e93b9ba8298b7fa52f83c33ed71a2e diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index d839ab6f75..c994ebfa69 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -57,6 +57,6 @@ Persistent-process pooling (reuse a warm child across runs) is a performance opt Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The parent surfaces only the child's final answer: `session/update` thoughts and tool-call cards are consumed and dropped, and permission prompts never reach a human — the configured policy answers them. The child's environment is credential-scrubbed by default, so its own model key is supplied explicitly via `config.env`. -## Future providers +## Product-provider siblings -The [Codex app-server provider](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols. +The [Codex app-server and Claude Code Agent SDK providers](2026-08-04-claude-code-and-codex-subagent-backends.md) apply the same out-of-process spawn/prompt/settle/cancel boundary as siblings registered by name. A2A remains a future sibling transport; the ACP backend proves that the common seam supports this boundary without owning product-private protocols. diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index e9027e282b..e3c651f752 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -57,6 +57,6 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` 每次运行都要付出一个全新子进程的代价(spawn + `initialize` + `newSession`)。父进程仅暴露子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示从不到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥需通过 `config.env` 显式提供。 -## 后续提供方 +## 兄弟产品提供方 -[Codex app-server 提供方](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。 +[Codex app-server 与 Claude Code Agent SDK 提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)作为按名称注册的兄弟提供方,采用同样的进程外启动/提示词/结算/取消边界。A2A 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持这项边界,而无需负责产品私有协议。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 35f832154b..341655c57f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_claude_code["subagent-claude-code"] pkg_subagent_codex["subagent-codex"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] @@ -892,6 +893,11 @@ flowchart TD pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess + pkg_subagent_claude_code --> pkg_invariants + pkg_subagent_claude_code --> pkg_llm + pkg_subagent_claude_code --> pkg_session + pkg_subagent_claude_code --> pkg_subagent + pkg_subagent_claude_code --> pkg_subprocess pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -1218,6 +1224,7 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index ac60e83f76..63570333f1 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent-claude-code/README.md -README.md: facaae300eeb8907076182a129aa216863dec8ec -README.zh.md: 75627cb54032edde07ec1ba5e21a058768515e29 +README.md: e19b119e355953a388ec4fca6a2db511e7eb43da +README.zh.md: 6d3b8719329691681582abd91b82abd167e89f6d diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index facaae300e..e19b119e35 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | -| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 75627cb540..6d3b871932 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -27,7 +27,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | 生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。 diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index dbb2af3593..2ad305f155 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -18,9 +18,9 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { - doubledGraceWindow, settleRunResult, subprocessRunHandle, + thrownError, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -39,32 +39,20 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -type QueryFactory = (params: { - prompt: string - options: Options -}) => Query - /** Fully resolved inputs for one official Claude Agent SDK query. */ export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ readonly cwd: string /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record - /** Subprocess termination grace and final tree-exit bound. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle - /** Official query entrypoint; replaced only by package-local unit tests. */ - readonly query?: QueryFactory /** Diagnostic sink for a post-publication error flattened into a result. */ readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } -function thrown(value: unknown): Error { - /* v8 ignore next -- SDK and subprocess failures reject with Error. */ - return value instanceof Error ? value : new Error(String(value)) -} - /** * Validate and preserve the one-shot task before crossing the SDK boundary. * @param prompt - task content accepted from the shared subagent service. @@ -110,18 +98,15 @@ export function successfulResult(message: SDKResultMessage): string { * Consume the complete SDK stream and require one strict success plus normal * iterator completion. * @param query - published official SDK query. - * @param setOutput - captures the candidate result for error diagnostics. * @returns the completed shared result. */ export async function consumeClaudeQuery( query: AsyncIterable, - setOutput: (output: ContentBlock[]) => void, ): Promise { let answer: string | undefined for await (const message of query) { if (message.type !== 'result') continue answer = successfulResult(message) - setOutput([{ type: 'text', text: answer }]) } if (answer === undefined) { throw new Error('subagent-claude-code: Claude Code ended without a result') @@ -137,48 +122,30 @@ export async function consumeClaudeQuery( * the subprocess owner to prove it is gone. * @param query - official SDK query, when creation reached that point. * @param child - shared-service handle that owns the CLI process tree. - * @param graceMs - termination grace used to bound final exit observation. */ export async function disposeClaudeCodeChild( query: Pick | undefined, child: SubprocessHandle, - graceMs: number, ): Promise { const failures: Error[] = [] - let treeExited = child.pid <= 0 try { query?.close() } catch (error: unknown) { - failures.push(thrown(error)) + failures.push(thrownError(error)) } if (child.pid > 0) { child.terminate() - const exitWindow = doubledGraceWindow(graceMs) try { - treeExited = await child.waitForExit(exitWindow.signal) - if (!treeExited) { - failures.push(new Error( - 'subagent-claude-code: Claude Code process tree did not exit within its dispose window', - )) - } + await child.waitForExit() } catch (error: unknown) { - failures.push(thrown(error)) - } finally { - exitWindow.cancel() + failures.push(thrownError(error)) } } - if (treeExited) { - try { - await child.done - } catch (error: unknown) { - failures.push(thrown(error)) - } - } else { - // The bounded tree observation owns teardown completion. Keep a later - // direct-child spawn failure observed without turning that bound into an - // unbounded wait. - void child.done.catch(() => {}) + try { + await child.done + } catch (error: unknown) { + failures.push(thrownError(error)) } const firstFailure = failures[0] @@ -244,7 +211,7 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined try { - query = (spec.query ?? officialQuery)({ + query = officialQuery({ prompt, options: claudeQueryOptions(spec, controller, (captured) => { child = captured @@ -264,10 +231,10 @@ export async function startClaudeCodeRun( requestCancel() if (child !== undefined) { try { - await disposeClaudeCodeChild(query, child, spec.disposeGraceMs) + await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [thrownError(error), thrownError(disposeError)], 'subagent-claude-code: startup failed and CLI cleanup also failed', ) } @@ -276,7 +243,7 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [thrownError(error), thrownError(disposeError)], 'subagent-claude-code: startup failed and query cleanup also failed', ) } @@ -285,17 +252,14 @@ export async function startClaudeCodeRun( if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrown(error) + throw thrownError(error) } - let output: ContentBlock[] = [] const publishedQuery = query const publishedChild = child const result = settleRunResult({ - attempt: () => consumeClaudeQuery(publishedQuery, (value) => { - output = value - }), - collectOutput: () => output, + attempt: () => consumeClaudeQuery(publishedQuery), + collectOutput: () => [], cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, @@ -311,7 +275,6 @@ export async function startClaudeCodeRun( teardown: () => disposeClaudeCodeChild( publishedQuery, publishedChild, - spec.disposeGraceMs, ), }) } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 1f02914163..8c8158fb28 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1,5 +1,6 @@ import { PassThrough } from 'node:stream' import type { + Options, Query, SDKMessage, SDKResultMessage, @@ -7,7 +8,15 @@ import type { } from '@anthropic-ai/claude-agent-sdk' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { afterEach, describe, expect, it, type Mock, vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -35,6 +44,18 @@ import { type ClaudeCodeRunSpec, } from '../src/run.ts' +type QueryFactory = (params: { + prompt: string + options: Options +}) => Query + +const queryMock = vi.hoisted(() => vi.fn()) + +vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({ + ...await importOriginal(), + query: queryMock, +})) + const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } }, @@ -56,7 +77,6 @@ interface FakeChildOptions { readonly stdin?: PassThrough | undefined readonly stdout?: PassThrough | undefined readonly exitOnTerminate?: boolean - readonly waitForExitResult?: boolean readonly waitForExitError?: Error readonly doneError?: Error } @@ -103,9 +123,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { if (options.waitForExitError !== undefined) { throw options.waitForExitError } - if (options.waitForExitResult !== undefined) { - return options.waitForExitResult - } if (exited) return true if (signal === undefined) { await done.catch(() => {}) @@ -218,7 +235,7 @@ interface FakeRun { readonly query: Query readonly close: ReturnType readonly spawnSpecs: SubprocessSpawnSpec[] - readonly options: Array>[0]['options']> + readonly options: Options[] readonly spec: ClaudeCodeRunSpec } @@ -239,16 +256,28 @@ function fakeRun( spawnSpecs.push(spawnSpec) return child.handle }, - query: (params) => { - options.push(params.options) - params.options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - return query - }, } + queryMock.mockImplementation((params) => { + options.push(params.options) + params.options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return query + }) return { child, query, close, spawnSpecs, options, spec } } +beforeEach(() => { + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions({ + cwd: options.cwd!, + env: options.env!, + signal: options.abortController!.signal, + })) + return queryFrom([]) + }) +}) + afterEach(() => { + queryMock.mockReset() vi.restoreAllMocks() vi.unstubAllEnvs() }) @@ -523,25 +552,17 @@ describe('query options and result mapping', () => { }) it('consumes the complete stream and keeps the latest strict success', async () => { - const outputs: ContentBlock[][] = [] const query = queryFrom([ { type: 'system', subtype: 'init' } as SDKMessage, success('first'), success('last'), ]) - await expect(consumeClaudeQuery(query, (output) => { - outputs.push(output) - })).resolves.toEqual({ + await expect(consumeClaudeQuery(query)).resolves.toEqual({ output: [{ type: 'text', text: 'last' }], stopReason: 'completed', }) - expect(outputs).toEqual([ - [{ type: 'text', text: 'first' }], - [{ type: 'text', text: 'last' }], - ]) await expect(consumeClaudeQuery( queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), - () => {}, )).rejects.toThrow('ended without a result') }) }) @@ -596,14 +617,14 @@ describe('run publication, cancellation, and settlement', () => { } }) - it('preserves candidate output when iteration fails after a result', async () => { + it('fails closed when iteration rejects after a result', async () => { const fixture = fakeRun( [success('partial final')], new Error('iterator boom'), ) const run = await startClaudeCodeRun(request(), fixture.spec) await expect(run.result).resolves.toEqual({ - output: [{ type: 'text', text: 'partial final' }], + output: [], stopReason: 'error', }) await run.dispose() @@ -635,14 +656,14 @@ describe('run publication, cancellation, and settlement', () => { env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, - query: ({ prompt, options }) => { - controllers.push(options.abortController!) - options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - return prompt === 'wait' - ? waitingQuery(options.abortController!.signal) - : queryFrom([success('second answer')]) - }, } + queryMock.mockImplementation(({ prompt, options }) => { + controllers.push(options.abortController!) + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return prompt === 'wait' + ? waitingQuery(options.abortController!.signal) + : queryFrom([success('second answer')]) + }) const firstAbort = new AbortController() const first = await startClaudeCodeRun( request([{ type: 'text', text: 'wait' }], firstAbort.signal), @@ -667,6 +688,33 @@ describe('run publication, cancellation, and settlement', () => { await Promise.all([first.dispose(), second.dispose()]) }) + it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => { + const parentAbort = new AbortController() + const child = fakeChild() + async function* stream(): AsyncGenerator { + yield success('candidate answer') + parentAbort.abort(new Error('parent cancelled at iterator completion')) + } + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return Object.assign(stream(), { close: vi.fn() }) as unknown as Query + }) + const run = await startClaudeCodeRun( + request(undefined, parentAbort.signal), + { + cwd: '/workspace', + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }, + ) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await run.dispose() + }) + it('rejects pre-abort and every incomplete startup transaction', async () => { const preAborted = new AbortController() preAborted.abort() @@ -678,32 +726,36 @@ describe('run publication, cancellation, and settlement', () => { expect(unused.options).toEqual([]) const noChildClose = vi.fn() + queryMock.mockImplementationOnce( + () => queryFrom([], undefined, noChildClose), + ) await expect(startClaudeCodeRun(request(), { ...unused.spec, - query: () => queryFrom([], undefined, noChildClose), })).rejects.toThrow('did not publish a controllable') expect(noChildClose).toHaveBeenCalledOnce() const closeFailure = vi.fn(() => { throw new Error('close boom') }) + queryMock.mockImplementationOnce( + () => queryFrom([], undefined, closeFailure), + ) const noChild = startClaudeCodeRun(request(), { ...unused.spec, - query: () => queryFrom([], undefined, closeFailure), }) await expect(noChild).rejects.toBeInstanceOf(AggregateError) const startupAbort = new AbortController() const abortedChild = fakeChild() const abortedClose = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + startupAbort.abort(new Error('startup cancelled')) + return queryFrom([], undefined, abortedClose) + }) const abortedDuringStartup = startClaudeCodeRun( request(undefined, startupAbort.signal), { ...unused.spec, spawn: () => abortedChild.handle, - query: ({ options }) => { - options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - startupAbort.abort(new Error('startup cancelled')) - return queryFrom([], undefined, abortedClose) - }, }, ) await expect(abortedDuringStartup) @@ -711,27 +763,27 @@ describe('run publication, cancellation, and settlement', () => { expect(abortedClose).toHaveBeenCalledOnce() expect(abortedChild.terminate).toHaveBeenCalledOnce() + queryMock.mockImplementationOnce(() => { + throw new Error('query failed before resource creation') + }) await expect(startClaudeCodeRun(request(), { ...unused.spec, - query: () => { - throw new Error('query failed before resource creation') - }, })).rejects.toThrow('query failed before resource creation') const spawned = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] let factoryController: AbortController | undefined + queryMock.mockImplementationOnce(({ options }) => { + factoryController = options.abortController + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw new Error('query construction failed') + }) const factoryFailure = startClaudeCodeRun(request(), { ...unused.spec, spawn: (spawnSpec) => { spawnSpecs.push(spawnSpec) return spawned.handle }, - query: ({ options }) => { - factoryController = options.abortController - options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - throw new Error('query construction failed') - }, }) await expect(factoryFailure).rejects.toThrow('query construction failed') expect(spawnSpecs).toHaveLength(1) @@ -749,76 +801,45 @@ describe('run publication, cancellation, and settlement', () => { }) }) -describe('bounded query and process disposal', () => { +describe('query and process disposal', () => { it('closes the query, terminates the tree, and waits for direct-child outcome', async () => { const child = fakeChild() const close = vi.fn() - await disposeClaudeCodeChild({ close }, child.handle, 5) + await disposeClaudeCodeChild({ close }, child.handle) expect(close).toHaveBeenCalledOnce() expect(child.terminate).toHaveBeenCalledOnce() expect(child.waitForExit).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledWith() await expect(child.handle.done).resolves.toEqual({ exitCode: 0, signal: null, }) }) - it('accepts fractional and larger-than-Node grace windows', async () => { - for (const graceMs of [0.25, Number.MAX_VALUE]) { - const child = fakeChild() - await expect(disposeClaudeCodeChild( - { close: vi.fn() }, - child.handle, - graceMs, - )).resolves.toBeUndefined() - const signal = child.waitForExit.mock.calls[0]?.[0] - expect(signal?.aborted).toBe(false) - } - }) - - it('chains a doubled grace window beyond one Node timer segment', async () => { - vi.useFakeTimers() - try { - const child = fakeChild({ exitOnTerminate: false }) - const disposal = disposeClaudeCodeChild( - { close: vi.fn() }, - child.handle, - 1_073_741_823.75, - ) - const rejected = expect(disposal) - .rejects.toThrow('did not exit within its dispose window') - await vi.advanceTimersByTimeAsync(2_147_483_647) - await vi.advanceTimersByTimeAsync(1) - await rejected - } finally { - vi.useRealTimers() - } - }) - - it('does not turn a missed tree-exit bound into an unbounded done wait', async () => { - const child = fakeChild({ - exitOnTerminate: false, - waitForExitResult: false, - }) - await expect(disposeClaudeCodeChild( + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + let disposed = false + const disposal = disposeClaudeCodeChild( { close: vi.fn() }, child.handle, - 5, - )).rejects.toThrow('did not exit within its dispose window') - child.fail(new Error('late direct-child failure')) + ).then(() => { + disposed = true + }) await nextTask() + expect(disposed).toBe(false) + child.settle() + await disposal + expect(disposed).toBe(true) }) it('reports wait, close, and direct-child failures without skipping cleanup', async () => { const waitFailure = fakeChild({ - exitOnTerminate: false, waitForExitError: new Error('wait boom'), }) const closeFailure = vi.fn(() => { throw new Error('close boom') }) await expect(disposeClaudeCodeChild( { close: closeFailure }, waitFailure.handle, - 5, )).rejects.toBeInstanceOf(AggregateError) expect(waitFailure.terminate).toHaveBeenCalledOnce() @@ -829,7 +850,6 @@ describe('bounded query and process disposal', () => { await expect(disposeClaudeCodeChild( { close: vi.fn() }, doneFailure.handle, - 5, )).rejects.toThrow('spawn boom') const both = fakeChild({ @@ -839,7 +859,6 @@ describe('bounded query and process disposal', () => { await expect(disposeClaudeCodeChild( { close: () => { throw new Error('close boom') } }, both.handle, - 5, )).rejects.toBeInstanceOf(AggregateError) }) }) diff --git a/packages/subagent/subagent-claude-code/tsconfig.json b/packages/subagent/subagent-claude-code/tsconfig.json index 72e5f73fec..61e81d7fcc 100644 --- a/packages/subagent/subagent-claude-code/tsconfig.json +++ b/packages/subagent/subagent-claude-code/tsconfig.json @@ -9,6 +9,12 @@ "src/**/*.ts" ], "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../llm/llm" }, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 3e8e805c88..c3d4da77bf 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent-codex/README.md -README.md: ce1c66427b562c08af06320f012f28b9e125ac45 -README.zh.md: bef47586db77c70bec741629d8579ba0e2efba1e +README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8 +README.zh.md: 110953312162e146f01ef037a40d2f70b136850c diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index ce1c66427b..d7293a0ef3 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | -| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index bef47586db..1109533121 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -23,7 +23,7 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index b07bf9dc6b..27d01f6ac6 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -11,9 +11,9 @@ import { randomUUID } from 'node:crypto' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { - doubledGraceWindow, settleRunResult, subprocessRunHandle, + thrownError, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -31,7 +31,7 @@ export interface CodexRunSpec { readonly cwd: string /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record - /** Subprocess termination grace and final tree-exit bound. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -39,11 +39,6 @@ export interface CodexRunSpec { readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } -function thrown(value: unknown): Error { - /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ - return value instanceof Error ? value : new Error(String(value)) -} - /** * Validate and preserve the one-shot task before crossing the process seam. * @param prompt - task content accepted from the shared subagent service. @@ -71,12 +66,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { * subprocess owner to prove it is gone. * @param wire - private app-server protocol connection. * @param child - shared-service handle that owns the process tree. - * @param graceMs - termination grace used to bound final exit observation. */ export async function disposeCodexChild( wire: CodexAppServerWire, child: SubprocessHandle, - graceMs: number, ): Promise { wire.close() if (child.pid <= 0) { @@ -89,14 +82,7 @@ export async function disposeCodexChild( // A concurrently closed stdin does not change tree ownership below. } child.terminate() - const exitWindow = doubledGraceWindow(graceMs) - try { - if (!(await child.waitForExit(exitWindow.signal))) { - throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') - } - } finally { - exitWindow.cancel() - } + await child.waitForExit() await child.done } @@ -127,15 +113,14 @@ export async function startCodexRun( child.stdout as NonNullable, child.stdin as NonNullable, ) - const disposeProcess = (): Promise => - disposeCodexChild(wire, child, spec.disposeGraceMs) + const disposeProcess = (): Promise => disposeCodexChild(wire, child) const processFailure: Promise = child.done.then( outcome => Promise.reject(new Error( 'subagent-codex: app-server exited before the run settled ' + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, )), - (error: unknown) => Promise.reject(thrown(error)), + (error: unknown) => Promise.reject(thrownError(error)), ) // A normal post-result dispose also closes the process. Keep that expected // late rejection observed after the result race has already settled. @@ -160,14 +145,14 @@ export async function startCodexRun( await disposeProcess() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [thrownError(error), thrownError(disposeError)], 'subagent-codex: startup failed and app-server cleanup also failed', ) } if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before app-server startup') } - throw thrown(error) + throw thrownError(error) } const collectOutput = (): ContentBlock[] => wire.collectOutput() diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 8e6c7ebd51..7d550d42cb 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -91,7 +91,6 @@ class ProtocolPeer { interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean - readonly waitForExitResult?: boolean readonly doneError?: Error } @@ -134,9 +133,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { if (options.exitOnTerminate !== false) settle() }) const waitForExit = vi.fn(async (signal?: AbortSignal) => { - if (options.waitForExitResult !== undefined) { - return options.waitForExitResult - } if (exited) return true if (signal === undefined) { await done.catch(() => {}) @@ -964,19 +960,6 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) - it('reports both startup and rollback failures', async () => { - const child = fakeChild({ waitForExitResult: false, exitOnTerminate: false }) - const starting = startCodexRun( - request(), - runSpec(child, { disposeGraceMs: 1 }), - ) - const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: '' }) - await expect(starting).rejects.toThrow( - 'startup failed and app-server cleanup also failed', - ) - }) - it('keeps overlapping runs isolated', async () => { const first = fakeChild() const second = fakeChild() @@ -1047,41 +1030,25 @@ describe('disposeCodexChild', () => { const child = fakeChild() const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) const end = vi.spyOn(child.toChild, 'end') - await disposeCodexChild(wire, child.handle, 100) + await disposeCodexChild(wire, child.handle) expect(end).toHaveBeenCalled() expect(child.terminate).toHaveBeenCalledTimes(1) expect(child.waitForExit).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledWith() }) - it('accepts fractional and larger-than-Node grace windows', async () => { - for (const graceMs of [0.25, Number.MAX_VALUE]) { - const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, graceMs)) - .resolves.toBeUndefined() - const signal = vi.mocked(child.waitForExit).mock.calls[0]?.[0] - expect(signal?.aborted).toBe(false) - } - }) - - it('chains a doubled grace window beyond one Node timer segment', async () => { - vi.useFakeTimers() - try { - const child = fakeChild({ exitOnTerminate: false }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - const disposal = disposeCodexChild( - wire, - child.handle, - 1_073_741_823.75, - ) - const rejected = expect(disposal) - .rejects.toThrow('did not exit within its dispose window') - await vi.advanceTimersByTimeAsync(2_147_483_647) - await vi.advanceTimersByTimeAsync(1) - await rejected - } finally { - vi.useRealTimers() - } + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + let disposed = false + const disposal = disposeCodexChild(wire, child.handle).then(() => { + disposed = true + }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(disposed).toBe(false) + child.settle() + await disposal + expect(disposed).toBe(true) }) it('contains a concurrently closed stdin error', async () => { @@ -1090,7 +1057,7 @@ describe('disposeCodexChild', () => { vi.spyOn(child.toChild, 'end').mockImplementation(() => { throw new Error('already closed') }) - await expect(disposeCodexChild(wire, child.handle, 100)) + await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() }) @@ -1100,34 +1067,26 @@ describe('disposeCodexChild', () => { doneError: new Error('spawn failed'), }) const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 100)) + await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() expect(child.terminate).not.toHaveBeenCalled() expect(child.waitForExit).not.toHaveBeenCalled() }) - it('fails when the tree misses the release window or done rejects', async () => { - { - const child = fakeChild({ - exitOnTerminate: false, - }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 1)) - .rejects.toThrow('did not exit within its dispose window') - } + it('reports direct-child observer failure and accepts absent stdin', async () => { { const child = fakeChild({ doneError: new Error('close observer failed'), }) const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 1)) + await expect(disposeCodexChild(wire, child.handle)) .rejects.toThrow('close observer failed') } { const child = fakeChild() const handle = { ...child.handle, stdin: undefined } const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, handle, 1)).resolves.toBeUndefined() + await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) }) diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index 86b0772283..eac2125897 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -42,49 +42,6 @@ export function assertPositiveFinite(prefix: string, name: string, value: number } } -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Bound final exit observation at twice a positive finite grace without - * narrowing public provider config to Node's single-timer integer range. - * @param graceMs - the already validated positive finite termination grace. - * @returns a cancellable abort signal for the doubled observation window. - */ -export function doubledGraceWindow(graceMs: number): { - readonly signal: AbortSignal - readonly cancel: () => void -} { - const whole = Math.floor(graceMs) - let remaining = BigInt(whole) * 2n - + BigInt(Math.ceil((graceMs - whole) * 2)) - const controller = new AbortController() - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - controller.abort() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - signal: controller.signal, - cancel: () => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - /** * Whether `path` names an existing directory the harness can ENTER. The * search-permission probe matters: `statSync().isDirectory()` is true for a @@ -162,8 +119,12 @@ export function resolveChildCwd(prefix: string, configured: string | undefined, return assertUsableCwd(prefix, 'parent session cwd', parentCwd) } -/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */ -function toError(value: unknown): Error { +/** + * Normalize an unknown thrown value to an Error. + * @param value - the unknown catch binding. + * @returns the original Error or a defensive Error wrapper. + */ +export function thrownError(value: unknown): Error { // The rejecting surfaces (wire clients, spawn failures) only throw // `Error`s; the `String(value)` arm is a defensive fallback for a non-Error // throw the typed surfaces cannot produce. @@ -175,9 +136,9 @@ function toError(value: unknown): Error { export interface RunResultSettlement { /** The turn attempt (typically racing local cancellation); returns the terminal result. */ attempt: () => Promise - /** Snapshot of the child output streamed so far (a partial answer survives failure). */ + /** Snapshot the provider exposes when cancellation or failure wins settlement. */ collectOutput: () => ContentBlock[] - /** Whether local cancellation settled (an in-flight rejection then reads as `aborted`). */ + /** Whether local cancellation settled before the attempt's outcome is observed. */ cancelled: () => boolean /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */ onError?: ((error: Error, stopReason: SubagentStopReason) => void) | undefined @@ -189,22 +150,25 @@ export interface RunResultSettlement { /** * Settle an out-of-process run result under the seam contract: `result` never - * rejects after publication. A rejection from the attempt resolves as - * `aborted` when cancellation already settled locally, else it is flattened - * to `stopReason: 'error'` through the contained diagnostic sink; the abort - * listener is removed on every path. + * rejects after publication. A normally completed or rejected attempt resolves + * as `aborted` when cancellation already settled locally; another rejection is + * flattened to `stopReason: 'error'` through the contained diagnostic sink. + * The abort listener is removed on every path. * @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring. * @returns the terminal result (never a rejection). */ export async function settleRunResult(parts: RunResultSettlement): Promise { try { - return await parts.attempt() + const result = await parts.attempt() + return parts.cancelled() + ? { output: parts.collectOutput(), stopReason: 'aborted' } + : result } catch (error: unknown) { // Cover a rejection already queued when cancellation arrives. if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: 'aborted' } // Flatten post-publication transport failures while preserving diagnostics. try { - parts.onError?.(toError(error), 'error') + parts.onError?.(thrownError(error), 'error') } catch { // The diagnostic sink cannot reject the run result. } diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index d7fb7e1b13..84eeeb10bb 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -189,6 +189,12 @@ describe('Node 24 lane ownership', () => { expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({ DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1', }) + expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual( + expect.arrayContaining([ + 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', + 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', + ]), + ) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..51bdf51f0e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -598,6 +598,8 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', + 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', + 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). From a93968bf9361a3240228d8e35bd43bfa1f7f7799 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 22:36:31 +0800 Subject: [PATCH 025/179] fix(subagent-codex): await managed tree exit --- docs/config-catalog.md | 4 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 2 +- packages/subagent/subagent-codex/README.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 59 +------------- .../tests/subagent-codex.spec.ts | 81 +++++-------------- packages/subagent/tool-subagent/src/index.ts | 4 +- 7 files changed, 32 insertions(+), 124 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d063d793e6..008d4ee2dd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1949,8 +1949,8 @@ export interface Config { * requires the provider's `depthLimit` capability (mount fails loud * otherwise). The provider checks the calling agent's current depth at every * start; the tool remains model-visible so runtime policy owns rejection. - * `'provider-managed'` is for an out-of-process provider (ACP) whose - * recursion budget belongs to the child harness's own deployment. + * `'provider-managed'` is for an out-of-process provider whose recursion + * budget belongs to the child runtime or its own deployment. */ maxDepth?: number | 'provider-managed' } diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 3e8e805c88..c3d4da77bf 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent-codex/README.md -README.md: ce1c66427b562c08af06320f012f28b9e125ac45 -README.zh.md: bef47586db77c70bec741629d8579ba0e2efba1e +README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8 +README.zh.md: 110953312162e146f01ef037a40d2f70b136850c diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index ce1c66427b..d7293a0ef3 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | -| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index bef47586db..1109533121 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -23,7 +23,7 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 811f7c8f98..9f52e18f12 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -24,54 +24,13 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Bound final exit observation at twice a positive finite grace without - * narrowing the public config to Node's single-timer integer range. - */ -function doubledGraceWindow(graceMs: number): { - readonly signal: AbortSignal - readonly cancel: () => void -} { - const whole = Math.floor(graceMs) - let remaining = BigInt(whole) * 2n - + BigInt(Math.ceil((graceMs - whole) * 2)) - const controller = new AbortController() - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - controller.abort() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - signal: controller.signal, - cancel: () => { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ readonly cwd: string /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record - /** Subprocess termination grace and final tree-exit bound. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -111,12 +70,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { * subprocess owner to prove it is gone. * @param wire - private app-server protocol connection. * @param child - shared-service handle that owns the process tree. - * @param graceMs - termination grace used to bound final exit observation. */ export async function disposeCodexChild( wire: CodexAppServerWire, child: SubprocessHandle, - graceMs: number, ): Promise { wire.close() if (child.pid <= 0) { @@ -129,14 +86,7 @@ export async function disposeCodexChild( // A concurrently closed stdin does not change tree ownership below. } child.terminate() - const exitWindow = doubledGraceWindow(graceMs) - try { - if (!(await child.waitForExit(exitWindow.signal))) { - throw new Error('subagent-codex: app-server process tree did not exit within its dispose window') - } - } finally { - exitWindow.cancel() - } + await child.waitForExit() await child.done } @@ -167,8 +117,7 @@ export async function startCodexRun( child.stdout as NonNullable, child.stdin as NonNullable, ) - const disposeProcess = (): Promise => - disposeCodexChild(wire, child, spec.disposeGraceMs) + const disposeProcess = (): Promise => disposeCodexChild(wire, child) const processFailure: Promise = child.done.then( outcome => Promise.reject(new Error( @@ -205,7 +154,7 @@ export async function startCodexRun( ) } if (runAbort.signal.aborted) { - throw new Error('subagent-codex: request was aborted before app-server startup') + throw new Error('subagent-codex: request was aborted before run publication') } throw thrown(error) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 8e6c7ebd51..181ddb919e 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -91,7 +91,6 @@ class ProtocolPeer { interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean - readonly waitForExitResult?: boolean readonly doneError?: Error } @@ -134,9 +133,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { if (options.exitOnTerminate !== false) settle() }) const waitForExit = vi.fn(async (signal?: AbortSignal) => { - if (options.waitForExitResult !== undefined) { - return options.waitForExitResult - } if (exited) return true if (signal === undefined) { await done.catch(() => {}) @@ -943,7 +939,7 @@ describe('run lifecycle and quiescence', () => { const threadStart = await child.peer.nextMethod('thread/start') child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) controller.abort('startup race') - await expect(starting).rejects.toThrow('aborted before app-server startup') + await expect(starting).rejects.toThrow('aborted before run publication') expect(child.terminate).toHaveBeenCalledTimes(1) }) @@ -964,19 +960,6 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) - it('reports both startup and rollback failures', async () => { - const child = fakeChild({ waitForExitResult: false, exitOnTerminate: false }) - const starting = startCodexRun( - request(), - runSpec(child, { disposeGraceMs: 1 }), - ) - const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: '' }) - await expect(starting).rejects.toThrow( - 'startup failed and app-server cleanup also failed', - ) - }) - it('keeps overlapping runs isolated', async () => { const first = fakeChild() const second = fakeChild() @@ -1047,41 +1030,25 @@ describe('disposeCodexChild', () => { const child = fakeChild() const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) const end = vi.spyOn(child.toChild, 'end') - await disposeCodexChild(wire, child.handle, 100) + await disposeCodexChild(wire, child.handle) expect(end).toHaveBeenCalled() expect(child.terminate).toHaveBeenCalledTimes(1) expect(child.waitForExit).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledWith() }) - it('accepts fractional and larger-than-Node grace windows', async () => { - for (const graceMs of [0.25, Number.MAX_VALUE]) { - const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, graceMs)) - .resolves.toBeUndefined() - const signal = vi.mocked(child.waitForExit).mock.calls[0]?.[0] - expect(signal?.aborted).toBe(false) - } - }) - - it('chains a doubled grace window beyond one Node timer segment', async () => { - vi.useFakeTimers() - try { - const child = fakeChild({ exitOnTerminate: false }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - const disposal = disposeCodexChild( - wire, - child.handle, - 1_073_741_823.75, - ) - const rejected = expect(disposal) - .rejects.toThrow('did not exit within its dispose window') - await vi.advanceTimersByTimeAsync(2_147_483_647) - await vi.advanceTimersByTimeAsync(1) - await rejected - } finally { - vi.useRealTimers() - } + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + let disposed = false + const disposal = disposeCodexChild(wire, child.handle).then(() => { + disposed = true + }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(disposed).toBe(false) + child.settle() + await disposal + expect(disposed).toBe(true) }) it('contains a concurrently closed stdin error', async () => { @@ -1090,7 +1057,7 @@ describe('disposeCodexChild', () => { vi.spyOn(child.toChild, 'end').mockImplementation(() => { throw new Error('already closed') }) - await expect(disposeCodexChild(wire, child.handle, 100)) + await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() }) @@ -1100,34 +1067,26 @@ describe('disposeCodexChild', () => { doneError: new Error('spawn failed'), }) const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 100)) + await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() expect(child.terminate).not.toHaveBeenCalled() expect(child.waitForExit).not.toHaveBeenCalled() }) - it('fails when the tree misses the release window or done rejects', async () => { - { - const child = fakeChild({ - exitOnTerminate: false, - }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 1)) - .rejects.toThrow('did not exit within its dispose window') - } + it('reports direct-child observer failure and accepts absent stdin', async () => { { const child = fakeChild({ doneError: new Error('close observer failed'), }) const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, child.handle, 1)) + await expect(disposeCodexChild(wire, child.handle)) .rejects.toThrow('close observer failed') } { const child = fakeChild() const handle = { ...child.handle, stdin: undefined } const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) - await expect(disposeCodexChild(wire, handle, 1)).resolves.toBeUndefined() + await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f95c5ad09d..67894c32cb 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -67,8 +67,8 @@ export interface Config { * requires the provider's `depthLimit` capability (mount fails loud * otherwise). The provider checks the calling agent's current depth at every * start; the tool remains model-visible so runtime policy owns rejection. - * `'provider-managed'` is for an out-of-process provider (ACP) whose - * recursion budget belongs to the child harness's own deployment. + * `'provider-managed'` is for an out-of-process provider whose recursion + * budget belongs to the child runtime or its own deployment. */ maxDepth?: number | 'provider-managed' } From 119c55e35eb6fff4d760029599c717a7fd23a099 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 23:11:15 +0800 Subject: [PATCH 026/179] fix(workspace-context): reconcile resumed baselines --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 4 +- .../2026-06-24-workspace-context.zh.md | 4 +- docs/config-catalog.md | 2 +- .../offline-edit/session.expected.jsonl | 2 +- .../precedence-change/session.expected.jsonl | 20 ++ .../workspace-context-resume.snapshot.ts | 107 +++++++++- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 8 +- .../context/workspace-context/README.zh.md | 8 +- .../context/workspace-context/src/config.ts | 23 ++ .../context/workspace-context/src/files.ts | 25 ++- .../context/workspace-context/src/index.ts | 61 ++++-- .../context/workspace-context/src/render.ts | 18 +- .../context/workspace-context/src/state.ts | 22 +- .../tests/workspace-context.spec.ts | 202 ++++++++++++++++++ 16 files changed, 463 insertions(+), 51 deletions(-) create mode 100644 examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index c127e30f7c..f0c2829e02 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-24-workspace-context.md -2026-06-24-workspace-context.md: a9d11f88ab9525a40f9bc58f817a088edc43105a -2026-06-24-workspace-context.zh.md: 1273bd9b460055a4b0e193267c5e9ad37bbeb0a2 +2026-06-24-workspace-context.md: 6fb739bf9a3bd508859c7ba4df499c847eba0570 +2026-06-24-workspace-context.zh.md: 51e43a4f2fcfa61973a9cc025a2c37818eb56bd1 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index a9d11f88ab..6fb739bf9a 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -32,7 +32,7 @@ At the first `agent/step` of a fresh session, the plugin injects one sourced use The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes a complete baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. -A resumed agent creates a new loop instance over persisted history. If a typed baseline remains in the visible surface, the loop retains that event and reconciles baseline plus dynamic scopes against current files before its first request. Unchanged files append nothing; files added, edited, or removed while the agent was offline append `set`, `replace`, or `remove` transitions without mutating or duplicating the original baseline. A hot plugin remount follows the same visibility rule. If no typed baseline remains visible, as after compaction shadows it, the loop composes and injects one complete current baseline. +A resumed agent creates a new loop instance over persisted history. If a typed baseline remains in the visible surface and its persisted discovery, precedence, and budget identity matches current configuration, the loop retains that event and reconciles dynamic scopes plus the baseline files retained by the current complete rendering before its first request. Unchanged files and budget-omitted files append nothing; files added, edited, removed, or dropped from the retained set while the agent was offline append `set`, `replace`, or `remove` transitions without mutating or duplicating the original baseline. An incompatible visible baseline is superseded by one recomposed complete baseline in current precedence order, with explicit model-facing replacement language; an empty current candidate set emits an explicit clear baseline. A hot plugin remount follows the same compatibility rule. If no typed baseline remains visible, as after compaction shadows it, the loop composes and injects one complete current baseline. Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, but restores only for an assembly explicitly marked for the loop's next model request; diagnostic assemblies remain read-only. When a prior typed baseline exists but none remains visible, the listener recomposes the current chain, rechecks cancellation and the current surface generation after every asynchronous probe, and injects before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker plus the synchronous commit-time recheck lets concurrent preparations scan without queuing duplicate baselines. @@ -84,7 +84,7 @@ Workspace guidance is isolated per session and shared by the demo front doors, W Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). -The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed baseline files are also noticed when a surface replacement or resume triggers recomposition. This keeps the design deterministic and provider-neutral. +The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed baseline files are also noticed when a surface replacement recomposes the baseline or resume reconciles its current retained set. This keeps the design deterministic and provider-neutral. ## Deferred diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 1273bd9b46..51e43a4f2f 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -32,7 +32,7 @@ Status: implemented 该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 -恢复 agent 会基于持久化历史创建新的 loop 实例。如果带类型的基线仍位于可见表层,loop 会保留该事件,并在第一个请求前根据当前文件对账基线与动态 scope。未变文件不追加任何内容;agent 离线期间新增、编辑或移除的文件会追加 `set`、`replace` 或 `remove` 转换,既不改写也不重复追加原始基线。插件热重挂遵循相同的可见性规则。如果已无带类型的基线可见(例如压缩(compaction)将其遮蔽后),loop 会组合并注入一条完整的当前基线。 +恢复 agent 会基于持久化历史创建新的 loop 实例。如果带类型的基线仍位于可见表层,且其持久化的发现、优先顺序和预算标识与当前配置匹配,loop 会保留该事件,并在第一个请求前对账动态 scope 与当前完整渲染所保留的基线文件。未变文件和预算省略的文件都不追加任何内容;agent 离线期间新增、编辑、移除或从保留集中退出的文件会追加 `set`、`replace` 或 `remove` 转换,既不改写也不重复追加原始基线。不兼容的可见基线会被一条按当前优先顺序重新组合的完整基线取代,并以面向模型的明确措辞说明替换关系;当前候选集为空时,则会发出一条显式清除基线。插件热重挂遵循相同的兼容性规则。如果已无带类型的基线可见(例如压缩(compaction)将其遮蔽后),loop 会组合并注入一条完整的当前基线。 在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,但只有当组装被明确标记为供 loop 的下一个模型请求使用时才恢复;诊断组装保持只读。如果此前存在带类型的基线、但已无基线可见,该监听器会重新组合当前文件链,在每次异步探测后重新检查取消状态和当前表层代次,并在 loop 排空 outbox 和对派生请求历史创建快照之前注入。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记加上提交时同步复查,使并发准备可以扫描而不会排入重复基线。 @@ -84,7 +84,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 -系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;表层替换或恢复触发重新组合时,也会发现外部变更的基线文件。这使设计保持确定性并且与提供方无关。 +系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;表层替换重新组合基线,或恢复过程对账当前保留集时,也会发现外部变更的基线文件。这使设计保持确定性并且与提供方无关。 ## 延后事项 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f85d39dcd7..5da8769a91 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2256,7 +2256,7 @@ export interface Config { } ``` -Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/context/workspace-context/src/config.ts) +Source: [`packages/context/workspace-context/src/config.ts:18`](../packages/context/workspace-context/src/config.ts) ## Loadable plugins with no config diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl index 9abcd67f44..9bdaaf9e75 100644 --- a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl @@ -1,7 +1,7 @@ {"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":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n"}],"source":{"kind":"workspace-instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","seq":4,"time":0,"data":{}} {"type":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl new file mode 100644 index 0000000000..79d87a18c6 --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl @@ -0,0 +1,20 @@ +{"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":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: CLAUDE.md\n\nOld CLAUDE rule.\n\nInstructions from: AGENTS.md\n\nOld AGENTS rule.\n"}],"source":{"kind":"workspace-instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"CLAUDE.md\",\"AGENTS.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"b525eb8a6d3660b732dad4b0aff1b7c63ab32890"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"3113bd093ae91976207dcef7390bdc0b2bfcfa10"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/end-seed","seq":4,"time":0,"data":{}} +{"type":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":9,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RESUME_DONE"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":18,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts index 1d1c03b71e..d962895839 100644 --- a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts +++ b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts @@ -19,12 +19,14 @@ import SessionStore, { } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { renderWorkspaceContext } from '@deepseek-ai/dsh-workspace-context' +import { resolveConfig, workspaceBaselineIdentity } from '@deepseek-ai/dsh-workspace-context/src/config.ts' import { describe, expect, it } from 'vitest' const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit') const replayFixture = join(fixtureDir, 'replay.jsonl') const replayOverride = join(fixtureDir, 'replay.override.json') const sessionExpected = join(fixtureDir, 'session.expected.jsonl') +const precedenceExpected = join(dirname(fixtureDir), 'precedence-change/session.expected.jsonl') const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -33,7 +35,16 @@ const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const oldInstruction = 'Old workspace instruction.' const newInstruction = 'New workspace instruction after offline edit.' -async function seedVisibleBaseline(root: string, cwd: string): Promise { +interface SeedBaselineOptions { + files?: Array<{ name: string; content: string }> + instructionFileCandidates?: string[] +} + +async function seedVisibleBaseline( + root: string, + cwd: string, + options: SeedBaselineOptions = {}, +): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) @@ -44,11 +55,19 @@ async function seedVisibleBaseline(root: string, cwd: string): Promise { cwd, delegationDepth: 0, } - const baseline = renderWorkspaceContext([{ - absolutePath: join(cwd, 'AGENTS.md'), - displayPath: 'AGENTS.md', - content: oldInstruction, - }], { maxBytes: 65536 }) + const files = options.files ?? [{ name: 'AGENTS.md', content: oldInstruction }] + const baseline = renderWorkspaceContext(files.map(file => ({ + absolutePath: join(cwd, file.name), + displayPath: file.name, + content: file.content, + })), { maxBytes: 65536 }) + const config = resolveConfig({ + dshHome: join(cwd, '.dsh'), + maxBytes: 65536, + ...options.instructionFileCandidates === undefined + ? {} + : { instructionFileCandidates: options.instructionFileCandidates }, + }) const events: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, { @@ -67,12 +86,13 @@ async function seedVisibleBaseline(root: string, cwd: string): Promise { source: { kind: 'workspace-instructions', baseline: true, - changes: [{ + baselineIdentity: workspaceBaselineIdentity(config, cwd, cwd), + changes: files.map(file => ({ action: 'set', - scope: '.\0AGENTS.md', - path: 'AGENTS.md', - digest: createHash('sha1').update(oldInstruction).digest('hex'), - }], + scope: `.\0${file.name}`, + path: file.name, + digest: createHash('sha1').update(file.content).digest('hex'), + })), }, }), surfaceOp: 'append', @@ -148,4 +168,69 @@ describe('workspace-context resume snapshot', () => { reason: { kind: 'completed' }, }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('recomposes a compatible current-order baseline when precedence changed offline', async () => { + let cwd = '' + let sessionPath = '' + const result = await runLoaderSmoke({ + label: 'workspace-context precedence-change resume snapshot', + tempDirPrefix: 'dsh-workspace-context-precedence-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', 'Acknowledge the current workspace instruction.'], + tsconfigPath, + env: { + DSH_SNAPSHOT_FILE: replayFixture, + DSH_SNAPSHOT_OVERRIDE: replayOverride, + }, + prepare: async (runCwd) => { + cwd = runCwd + await mkdir(join(runCwd, '.git'), { recursive: true }) + await writeFile(join(runCwd, 'AGENTS.md'), 'Current AGENTS rule.\n') + await writeFile(join(runCwd, 'CLAUDE.md'), 'Current CLAUDE rule.\n') + sessionPath = await seedVisibleBaseline(join(runCwd, '.sessions'), runCwd, { + files: [ + { name: 'CLAUDE.md', content: 'Old CLAUDE rule.' }, + { name: 'AGENTS.md', content: 'Old AGENTS rule.' }, + ], + instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], + }) + }, + inspect: async () => { + const normalization: NormalizeContext = { sessionIds: [sessionId], cwd } + const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization)) + if (refreshing) { + await mkdir(dirname(precedenceExpected), { recursive: true }) + await writeFile(precedenceExpected, session) + } + expect(session).toBe(await readFile(precedenceExpected, 'utf8')) + + const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as { + type?: string + data?: { + source?: { kind?: string; baseline?: boolean } + content?: Array<{ type?: string; text?: string }> + } + }) + const baselines = records.filter(record => record.type === 'user/message' + && record.data?.source?.kind === 'workspace-instructions' + && record.data.source.baseline === true) + expect(baselines).toHaveLength(2) + const replacement = JSON.stringify(baselines.at(-1)?.data?.content) + expect(replacement).toContain('replaces all earlier workspace instruction baselines') + expect(replacement.indexOf('Instructions from: AGENTS.md')) + .toBeLessThan(replacement.indexOf('Instructions from: CLAUDE.md')) + }, + }) + + expect(result.stderr).toBe('') + expect(result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record).at(-1)) + .toMatchObject({ + type: 'result', + success: true, + sessionId, + result: 'RESUME_DONE', + reason: { kind: 'completed' }, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 478bf7e052..25292228e3 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/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/context/workspace-context/README.md -README.md: 79f913a1cbabbcf474c5befccf01fb6eae76e843 -README.zh.md: 02f88c4e339b6a816b49db1eb9f91a440df787df +README.md: 16a1dd324b4ef8d87253d0cd0de1b61b850ae8be +README.zh.md: 63ed50162174469c1a2d3c254cbfff216bf73c14 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 79f913a1cb..16a1dd324b 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -A complete baseline is injected at the first `agent/step` of a fresh session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. A resumed loop retains that baseline while it remains visible and appends only current-file transitions. If a later surface replacement such as compaction shadows the baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session. +A complete baseline is injected at the first `agent/step` of a fresh session. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The durable sourced `user/message` enters the same request as the claimed prompt. A resumed loop retains that baseline while it remains visible and its discovery, precedence, and budget identity matches the current configuration, then appends only transitions selected by the current baseline budget. An incompatible visible baseline is superseded by one recomposed complete baseline whose model-facing introduction states that replacement; if the current candidate set is empty, that baseline explicitly clears the earlier instructions. If a later surface replacement such as compaction shadows the baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. @@ -48,11 +48,11 @@ The plugin owns the complete `` framing, and every injected `us ## State And Refresh -Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, and budget configuration. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. -The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. A resumed loop or hot plugin remount retains that one visible baseline and reconciles its baseline and dynamic scopes against current files before the first request: unchanged files append nothing, while offline additions, edits, and removals append typed `set`, `replace`, and `remove` transitions. If no typed baseline remains visible, as after a surface replacement, model-request prompt assembly recomposes the complete current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. The in-memory scope marker and provider-version cache only select and accelerate probes. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, or when a resumed loop prepares its baseline. +The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. A resumed loop or hot plugin remount retains one compatible visible baseline and reconciles its dynamic scopes plus the baseline scopes retained by the current complete rendering before the first request: unchanged files append nothing, while offline additions, edits, and removals append typed `set`, `replace`, and `remove` transitions. A file omitted by the current baseline budget is not promoted into history by resume reconciliation; a previously visible file that leaves the retained set receives a removal transition. If the visible baseline identity is incompatible, one complete current baseline explicitly supersedes earlier baselines. If no typed baseline remains visible, as after a surface replacement, model-request prompt assembly recomposes the complete current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. The in-memory scope marker and provider-version cache only select and accelerate probes. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, or when a resumed loop prepares its baseline. ## Configuration @@ -83,7 +83,7 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even #### What the model sees -A fresh session's first request contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. A resumed request retains that message while it remains visible and adds only detected transitions; the first request after a surface replacement shadows it receives one recomposed complete baseline. +A fresh session's first request contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. A resumed request retains that message while it remains visible and compatible, then adds only budget-selected transitions. An incompatible visible baseline is followed by a complete replacement baseline in current precedence order; the first request after a surface replacement shadows a baseline receives one recomposed complete baseline. ##### Baseline instruction template diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index 02f88c4e33..63ed501621 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -6,7 +6,7 @@ ## 生命周期 -完整基线会在全新会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。恢复的 loop 会在该基线仍可见时保留它,只追加根据当前文件检测到的转换。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。 +完整基线会在全新会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。恢复的 loop 会在该基线仍可见,且其发现、优先顺序和预算标识与当前配置匹配时保留它,然后只追加由当前基线预算选中的转换。不兼容的可见基线会被一条重新组合的完整基线取代,其面向模型的引言会明确说明替换关系;如果当前候选集为空,这条基线会显式清除先前的指令。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。 该插件还会监听 `tools/post-execute` 中成功的第一方 `read`、`write` 和 `edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。 @@ -48,11 +48,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when ## 状态与刷新 -模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 +模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及根据规范化的发现、优先顺序和预算配置派生的 `baselineIdentity`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。恢复的 loop 或插件热重挂会保留这一条可见基线,并在第一个请求前根据当前文件对账其基线和动态 scope:未变文件不追加任何内容,而 agent 离线期间新增、编辑或移除的文件会追加带类型的 `set`、`replace` 或 `remove` 转换。如果已无带类型的基线可见(例如表层替换后),面向模型请求的提示词组装会重新组合完整的当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。恢复的 loop 或插件热重挂会保留一条兼容的可见基线,并在第一个请求前对账其动态 scope 以及当前完整渲染所保留的基线 scope:未变文件不追加任何内容,而 agent 离线期间新增、编辑或移除的文件会追加带类型的 `set`、`replace` 或 `remove` 转换。当前基线预算省略的文件不会因恢复对账而进入历史;先前可见但已不在保留集内的文件会收到移除转换。如果可见基线标识不兼容,一条完整的当前基线会明确取代此前的基线。如果已无带类型的基线可见(例如表层替换后),面向模型请求的提示词组装会重新组合完整的当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 ## 配置 @@ -83,7 +83,7 @@ export interface Config { #### 模型看到的内容 -全新会话的第一个请求包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。恢复后的请求会在该消息仍可见时保留它,并只追加检测到的转换;表层替换将其遮蔽后的第一个请求会收到一条重新组合的完整基线。 +全新会话的第一个请求包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。恢复后的请求会在该消息仍可见且兼容时保留它,然后只追加预算选中的转换。不兼容的可见基线之后会跟随一条按当前优先顺序排列的完整替换基线;表层替换将基线遮蔽后的第一个请求会收到一条重新组合的完整基线。 ##### 基线指令模板 diff --git a/packages/context/workspace-context/src/config.ts b/packages/context/workspace-context/src/config.ts index 56c048976c..c1a1fad1e6 100644 --- a/packages/context/workspace-context/src/config.ts +++ b/packages/context/workspace-context/src/config.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-workspace-context/config */ +import { relative } from 'node:path' import z from 'schemastery' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -58,6 +59,28 @@ export interface ResolvedConfig extends ResolvedDiscoveryConfig { maxSourceBytes: number } +/** + * Identify the discovery, precedence, and budget semantics of one baseline. + * @param config - normalized plugin configuration. + * @param cwd - absolute session working directory. + * @param projectRoot - project root selected for the current baseline. + * @returns stable serialized identity for compatibility checks on resume. + */ +export function workspaceBaselineIdentity( + config: ResolvedConfig, + cwd: string, + projectRoot: string, +): string { + return JSON.stringify({ + projectRoot: relative(cwd, projectRoot), + projectRootMarkers: config.projectRootMarkers, + maxBytes: config.maxBytes, + maxSourceBytes: config.maxSourceBytes, + instructionFileCandidates: config.instructionFileCandidates, + localInstructionFileCandidates: config.localInstructionFileCandidates, + }) +} + /** * Resolve defaults, the harness home, and valid same-directory candidates. * @param config - user-facing plugin configuration. diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index 3e6a3d5de8..bc426e43a4 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -46,12 +46,14 @@ interface DiscoverOptions { projectRootMarkers?: string[] instructionFileCandidates?: string[] localInstructionFileCandidates?: string[] + projectRoot?: string signal?: AbortSignal } interface LoadOptions extends DiscoverOptions { maxBytes: number maxSourceBytes?: number + replacePreviousBaseline?: boolean } /** Rendered baseline plus the files that survived byte budgeting. */ @@ -286,7 +288,8 @@ async function discoverInstructionFiles( } const cwd = resolve(options.cwd) - const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal) + const projectRoot = options.projectRoot + ?? await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal) for (const dir of ancestorChain(projectRoot, cwd)) { for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) { for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) { @@ -389,7 +392,7 @@ export async function loadBaselineInstructions( * Load a baseline together with the files retained after rendering. * @param options - discovery, source-size, byte-budget, and cancellation configuration. * @param fileSystem - optional provider used instead of host filesystem reads. - * @returns rendered context and retained files, or undefined when empty or disabled. + * @returns rendered context and retained files, an explicit empty replacement set, or undefined when empty or disabled. */ export async function loadBaselineInstructionSet( options: LoadOptions, @@ -412,8 +415,22 @@ export async function loadBaselineInstructionSet( } } const deduped = dedupInstructionFilesByDirectory(loaded) - if (deduped.length === 0) return undefined - const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes }) + if (deduped.length === 0) { + if (options.replacePreviousBaseline !== true) return undefined + return { + rendered: renderWorkspaceContext([], { + maxBytes: config.maxBytes, + replacePreviousBaseline: true, + }), + included: [], + } + } + const rendered = renderWorkspaceContext(deduped, { + maxBytes: config.maxBytes, + ...options.replacePreviousBaseline === undefined + ? {} + : { replacePreviousBaseline: options.replacePreviousBaseline }, + }) const omitted = new Set(rendered.omitted.map(file => file.absolutePath)) return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) } } diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index ad1cade619..117072b5ce 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -15,8 +15,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import { Config, resolveConfig, type ResolvedConfig } from './config.ts' -import { loadBaselineInstructionSet } from './files.ts' +import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' +import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' import { applyInstructionVersionUpdates, baselineInstructionState, @@ -31,6 +31,7 @@ import { type InstructionVersionCache, type InstructionVersionUpdate, type PendingInstructionChange, + type WorkspaceInstructionSource, } from './state.ts' import type { WorkspaceInstructionChange } from './render.ts' @@ -46,13 +47,18 @@ export type { export { renderWorkspaceContext } from './render.ts' export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts' -function hasVisibleBaseline(session: Agent['session']): boolean { - return session.surface.nodes.some((seq) => { +function visibleBaselineSource(session: Agent['session']): WorkspaceInstructionSource | undefined { + for (const seq of session.surface.nodes.toReversed()) { const event = session.events[seq] - return event?.type === 'user/message' + if (event?.type === 'user/message' && event.data.source.kind === 'workspace-instructions' - && event.data.source.baseline === true - }) + && event.data.source.baseline === true) return event.data.source + } + return undefined +} + +function hasVisibleBaseline(session: Agent['session']): boolean { + return visibleBaselineSource(session) !== undefined } function hasBaselineHistory(session: Agent['session']): boolean { @@ -88,7 +94,7 @@ export function apply(ctx: Context, config: Config): void { const prepareBaseline = async ( agent: Agent, signal: AbortSignal | undefined, - keepVisibleBaseline: boolean, + retainCompatibleBaseline: boolean, deduplicateRestore = false, ): Promise => { if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { @@ -106,6 +112,21 @@ export function apply(ctx: Context, config: Config): void { } /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = agent.session.header.cwd ?? process.cwd() + const projectRoot = await findProjectRoot( + cwd, + resolved.projectRootMarkers, + fileSystem, + signal, + ) + const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot) + const visibleBaseline = visibleBaselineSource(agent.session) + const keepVisibleBaseline = retainCompatibleBaseline + && visibleBaseline !== undefined + && typeof visibleBaseline.baselineIdentity === 'string' + && visibleBaseline.baselineIdentity === identity + const replacePreviousBaseline = retainCompatibleBaseline + && visibleBaseline !== undefined + && !keepVisibleBaseline const instructions = await loadBaselineInstructionSet({ cwd, dshHome: resolved.dshHome, @@ -114,6 +135,8 @@ export function apply(ctx: Context, config: Config): void { maxSourceBytes: resolved.maxSourceBytes, instructionFileCandidates: resolved.instructionFileCandidates, localInstructionFileCandidates: resolved.localInstructionFileCandidates, + projectRoot, + replacePreviousBaseline, ...signal === undefined ? {} : { signal }, }, fileSystem) const baseline = baselineInstructionState(instructions?.included ?? []) @@ -126,7 +149,12 @@ export function apply(ctx: Context, config: Config): void { pendingNestedChanges, instructionVersions, fileSystem, - { includeBaselineScopes: keepVisibleBaseline, ...signal === undefined ? {} : { signal } }, + { + includeBaselineScopes: keepVisibleBaseline, + ...keepVisibleBaseline ? { retainedBaselineScopes: new Set(baseline.changes.keys()) } : {}, + projectRoot, + ...signal === undefined ? {} : { signal }, + }, ) signal?.throwIfAborted() const generation = agent.session.surface.replaceGeneration @@ -141,6 +169,15 @@ export function apply(ctx: Context, config: Config): void { } if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) { const baselineMessage = workspaceContextMessage(instructions.rendered.text) + const replacementScopes = new Set(baseline.changes.keys()) + const visibleBaselineChanges = visibleBaseline?.changes ?? [] + const replacementRemovals = replacePreviousBaseline + ? visibleBaselineChanges.flatMap(change => ( + change.action === 'remove' || replacementScopes.has(change.scope) + ? [] + : [{ action: 'remove' as const, scope: change.scope, path: change.path }] + )) + : [] baselineSettledGeneration.delete(agent.session) baselineQueuedGeneration.set(agent.session, generation) try { @@ -149,7 +186,8 @@ export function apply(ctx: Context, config: Config): void { source: { kind: 'workspace-instructions', baseline: true, - changes: [...baseline.changes.values()], + baselineIdentity: identity, + changes: [...replacementRemovals, ...baseline.changes.values()], }, })) } catch (error: unknown) { @@ -165,8 +203,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { if (baselineLoaded.has(agent.session)) return - const keepVisibleBaseline = hasVisibleBaseline(agent.session) - await prepareBaseline(agent, signal, keepVisibleBaseline) + await prepareBaseline(agent, signal, true) }) ctx.on('system-prompt/assemble', async (_assembly, context, next) => { diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 9ab311e942..7b61f3b8fd 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -12,6 +12,10 @@ const SYSTEM_REMINDER_CLOSE = '' const WORKSPACE_CONTEXT_INTRO = 'The following workspace instructions may be relevant to your work. ' + 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. ' + 'They do not override system, developer, or direct user instructions.' +const REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. ' + + WORKSPACE_CONTEXT_INTRO +const EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. ' + + 'No workspace instructions are currently active.' const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.' /** Byte-accounting record for one truncated instruction file. */ @@ -294,12 +298,20 @@ function renderInstructionContext( /** * Render the baseline instruction chain with deterministic precedence budgeting. * @param files - loaded files ordered from broadest to most specific. - * @param options - required rendering byte budget. + * @param options - rendering byte budget and whether this baseline supersedes a visible predecessor. * @returns bounded baseline prompt text and budget diagnostics. */ export function renderWorkspaceContext( files: LoadedInstructionFile[], - options: { maxBytes: number }, + options: { maxBytes: number; replacePreviousBaseline?: boolean }, ): RenderedWorkspaceContext { - return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) + const style = options.replacePreviousBaseline === true + ? { + ...BASELINE_RENDER_STYLE, + intro: files.length === 0 + ? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO + : REPLACEMENT_WORKSPACE_CONTEXT_INTRO, + } + : BASELINE_RENDER_STYLE + return renderInstructionContext(files, options.maxBytes, style) } diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 383c765719..25fec1aa1d 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -41,6 +41,8 @@ export interface WorkspaceInstructionSource { kind: 'workspace-instructions' /** Marks a complete baseline rather than a later delta. */ baseline?: true + /** Discovery, precedence, and budget identity for safe baseline reuse. */ + baselineIdentity?: string changes: WorkspaceInstructionChange[] } @@ -386,7 +388,7 @@ function relativeScope(projectRoot: string, dir: string): string { * @param pendingBySession - short pending window before returned context is logged. * @param versionCache - per-session scope metadata used to skip unchanged reads. * @param fileSystem - provider used for current file probes. - * @param options - touched path and whether baseline scopes should participate. + * @param options - touched path and baseline-scope selection. * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable. */ export async function reconcileInstructionContext( @@ -395,7 +397,13 @@ export async function reconcileInstructionContext( pendingBySession: WeakMap>, versionCache: InstructionVersionCache, fileSystem: FileSystem, - options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal }, + options: { + touchedPath?: string + includeBaselineScopes: boolean + retainedBaselineScopes?: ReadonlySet + projectRoot?: string + signal?: AbortSignal + }, ): Promise { const session = agent.session const pending = pendingChangesFor(session, pendingBySession) @@ -404,7 +412,8 @@ export async function reconcileInstructionContext( const cwd = session.header.cwd ?? process.cwd() // TODO(frozen-project-root): retain the baseline root for the loop instance; // recomputing it after marker edits reinterprets the existing relative scope keys. - const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal) + const projectRoot = options.projectRoot + ?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal) const scopes = new Set() const baselineScopes = new Set() const addDirScopes = (target: Set, directory: string): void => { @@ -455,6 +464,13 @@ export async function reconcileInstructionContext( for (const scope of scopes) { const { directory } = decodeScopeKey(scope) const previous = effective.get(scope) + if (options.retainedBaselineScopes !== undefined + && baselineScopes.has(scope) + && !options.retainedBaselineScopes.has(scope)) { + if (previous === undefined || previous.action === 'remove') versions.delete(scope) + else pushRemoval(scope, previous.path) + continue + } const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal) if (probe.kind === 'unavailable') { // Last-good-state: the candidate stays effective, so its cached trimmed diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index ed8f36c11c..13f9eebd34 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1063,6 +1063,208 @@ describe('workspace context request injection', () => { } }) + it('does not promote an unchanged budget-omitted baseline file during resume', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'pkg') + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'root '.repeat(200)) + await write(join(cwd, 'AGENTS.md'), 'package rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) + const original = stubAgent(cwd) + await composeBaselinePrefix(ctx, original) + + const firstResume = stubAgent(cwd, [...original.session.events]) + await composeBaselinePrefix(ctx, firstResume) + const secondResume = stubAgent(cwd, [...firstResume.session.events]) + await composeBaselinePrefix(ctx, secondResume) + + expect(baselineEvents(secondResume)).toHaveLength(1) + expect(secondResume.session.events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions')).toHaveLength(1) + expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md') + expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('removes a previously visible baseline file that leaves the retained budget set', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'pkg') + await mkdir(join(root, '.git'), { recursive: true }) + await mkdir(cwd, { recursive: true }) + await write(join(root, 'AGENTS.md'), 'root '.repeat(200)) + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) + const original = stubAgent(cwd) + await composeBaselinePrefix(ctx, original) + + await write(join(cwd, 'AGENTS.md'), 'package rule') + const resumed = stubAgent(cwd, [...original.session.events]) + await composeBaselinePrefix(ctx, resumed) + + expect(baselineEvents(resumed)).toHaveLength(1) + const update = resumed.session.events.findLast(event => event.type === 'user/message' + && event.data.source.kind === 'workspace-instructions' + && event.data.source.baseline !== true) + expect(update?.type === 'user/message' && update.data.source.changes).toMatchObject([ + { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, + { action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }, + ]) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('recomposes the baseline when candidate precedence changes between resumes', async () => { + const root = await tempRepo() + const home = await tempRepo() + const originalCtx = new Context() + const resumedCtx = new Context() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'agents rule') + await write(join(root, 'CLAUDE.md'), 'claude rule') + await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await composeBaselinePrefix(originalCtx, original) + + await mountWorkspaceContext(resumedCtx, { + dshHome: home, + maxBytes: 65536, + instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], + }) + const resumed = stubAgent(root, [...original.session.events]) + await composeBaselinePrefix(resumedCtx, resumed) + + const baselines = baselineEvents(resumed) + expect(baselines).toHaveLength(2) + const replacement = baselines.at(-1) + const replacementText = replacement?.type === 'user/message' + ? blocksText(replacement.data.content) + : '' + expect(replacementText).toContain('replaces all earlier workspace instruction baselines') + expect(replacementText.indexOf('Instructions from: CLAUDE.md')) + .toBeLessThan(replacementText.indexOf('Instructions from: AGENTS.md')) + const baselineIdentities: string[] = [] + for (const event of baselines) { + if (event.type !== 'user/message' || event.data.source.kind !== 'workspace-instructions') continue + if (typeof event.data.source.baselineIdentity === 'string') { + baselineIdentities.push(event.data.source.baselineIdentity) + } + } + expect(new Set(baselineIdentities).size).toBe(2) + + const repeated = stubAgent(root, [...resumed.session.events]) + await composeBaselinePrefix(resumedCtx, repeated) + expect(baselineEvents(repeated)).toHaveLength(2) + } finally { + await originalCtx.fiber.dispose() + await resumedCtx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('tombstones candidates removed across successive baseline configurations', async () => { + const root = await tempRepo() + const home = await tempRepo() + const agentsCtx = new Context() + const claudeCtx = new Context() + const restoredCtx = new Context() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'agents rule') + await write(join(root, 'CLAUDE.md'), 'claude rule') + await mountWorkspaceContext(agentsCtx, { + dshHome: home, + maxBytes: 65536, + instructionFileCandidates: ['AGENTS.md'], + }) + const original = stubAgent(root) + await composeBaselinePrefix(agentsCtx, original) + + await mountWorkspaceContext(claudeCtx, { + dshHome: home, + maxBytes: 65536, + instructionFileCandidates: ['CLAUDE.md'], + }) + const claudeResume = stubAgent(root, [...original.session.events]) + await composeBaselinePrefix(claudeCtx, claudeResume) + const claudeBaseline = baselineEvents(claudeResume).at(-1) + expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.changes).toMatchObject([ + { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, + { action: 'set', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' }, + ]) + + await mountWorkspaceContext(restoredCtx, { + dshHome: home, + maxBytes: 65536, + instructionFileCandidates: ['AGENTS.md'], + }) + const restored = stubAgent(root, [...claudeResume.session.events]) + await composeBaselinePrefix(restoredCtx, restored) + const restoredBaseline = baselineEvents(restored).at(-1) + expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.changes).toMatchObject([ + { action: 'remove', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' }, + { action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, + ]) + } finally { + await agentsCtx.fiber.dispose() + await claudeCtx.fiber.dispose() + await restoredCtx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('supersedes an incompatible visible baseline when no current candidate exists', async () => { + const root = await tempRepo() + const home = await tempRepo() + const originalCtx = new Context() + const resumedCtx = new Context() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'agents rule') + await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await composeBaselinePrefix(originalCtx, original) + + await mountWorkspaceContext(resumedCtx, { + dshHome: home, + maxBytes: 65536, + instructionFileCandidates: ['POLICY.md'], + }) + const resumed = stubAgent(root, [...original.session.events]) + await composeBaselinePrefix(resumedCtx, resumed) + + const baselines = baselineEvents(resumed) + expect(baselines).toHaveLength(2) + const replacement = baselines.at(-1) + expect(replacement?.type === 'user/message' && blocksText(replacement.data.content)) + .toContain('No workspace instructions are currently active.') + expect(replacement?.type === 'user/message' && replacement.data.source.changes).toMatchObject([ + { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, + ]) + + const repeated = stubAgent(root, [...resumed.session.events]) + await composeBaselinePrefix(resumedCtx, repeated) + expect(baselineEvents(repeated)).toHaveLength(2) + } finally { + await originalCtx.fiber.dispose() + await resumedCtx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('ignores a restored baseline during a file tool call before resume reconciliation', async () => { const root = await tempRepo() const home = await tempRepo() From f8ceeed610b82fddf3c0d0a10a996a2bef80b2ec Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 23:12:56 +0800 Subject: [PATCH 027/179] test(workspace-context): narrow instruction events --- .../tests/workspace-context.spec.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 13f9eebd34..b38e89c7d7 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -40,6 +40,7 @@ import { rollbackPendingInstructionChanges, type InstructionVersionCache, type PendingInstructionChange, + type WorkspaceInstructionSource, } from '../src/state.ts' import { candidateScopeKey, renderInstructionChanges } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -227,11 +228,18 @@ function workspaceContextOf(result: { additionalContexts?: UserMessage[] }): Use context.source.kind === 'workspace-instructions') } -function baselineEvents(agent: Agent): SessionEvent[] { - return agent.session.events.filter(event => +type WorkspaceInstructionEvent = Extract & { + data: { source: WorkspaceInstructionSource } +} + +function workspaceInstructionEvents(agent: Agent): WorkspaceInstructionEvent[] { + return agent.session.events.filter((event): event is WorkspaceInstructionEvent => event.type === 'user/message' - && event.data.source.kind === 'workspace-instructions' - && event.data.source.baseline === true) + && event.data.source.kind === 'workspace-instructions') +} + +function baselineEvents(agent: Agent): WorkspaceInstructionEvent[] { + return workspaceInstructionEvents(agent).filter(event => event.data.source.baseline === true) } function workspaceChangeContext(scope: string, digest: string): UserMessage { @@ -1110,10 +1118,9 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - const update = resumed.session.events.findLast(event => event.type === 'user/message' - && event.data.source.kind === 'workspace-instructions' - && event.data.source.baseline !== true) - expect(update?.type === 'user/message' && update.data.source.changes).toMatchObject([ + const update = workspaceInstructionEvents(resumed) + .findLast(event => event.data.source.baseline !== true) + expect(update?.data.source.changes).toMatchObject([ { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, { action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }, ]) @@ -1199,7 +1206,7 @@ describe('workspace context request injection', () => { const claudeResume = stubAgent(root, [...original.session.events]) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) - expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.changes).toMatchObject([ + expect(claudeBaseline?.data.source.changes).toMatchObject([ { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, { action: 'set', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' }, ]) @@ -1212,7 +1219,7 @@ describe('workspace context request injection', () => { const restored = stubAgent(root, [...claudeResume.session.events]) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) - expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.changes).toMatchObject([ + expect(restoredBaseline?.data.source.changes).toMatchObject([ { action: 'remove', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' }, { action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) @@ -1248,9 +1255,9 @@ describe('workspace context request injection', () => { const baselines = baselineEvents(resumed) expect(baselines).toHaveLength(2) const replacement = baselines.at(-1) - expect(replacement?.type === 'user/message' && blocksText(replacement.data.content)) + expect(replacement === undefined ? '' : blocksText(replacement.data.content)) .toContain('No workspace instructions are currently active.') - expect(replacement?.type === 'user/message' && replacement.data.source.changes).toMatchObject([ + expect(replacement?.data.source.changes).toMatchObject([ { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) From c0433d71ee057170d3629212a73530e19c88a289 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 23:20:51 +0800 Subject: [PATCH 028/179] test(workspace-context): record baseline identity --- .../tests/snapshots/code-mode-workspace-context/session.jsonl | 2 +- .../acp-agent/tests/snapshots/workspace-context/session.jsonl | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 4588ebb898..93742afdfb 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"a5066d26-ed57-4f98-8672-b34e883e1299"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785122256262,"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":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464674590,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464674590,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487644564,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 41faaef25f..71ceebedec 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"950c77c7-6a48-43aa-8e72-b6068d4e876b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"95aaf126-946e-4ada-985a-943b490b6f2f"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"95aaf126-946e-4ada-985a-943b490b6f2f"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -18,7 +18,7 @@ {"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"24e6c34c-3fea-462b-8399-5d8b8c14eb9c"},"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785487608790,"data":{"turn":1,"step":1}} {"type":"user/message","seq":18,"time":1785762637747,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"5413be2d-cb6c-490c-9fa3-64b95c20b72b"},"sourceEventSeqs":[4],"surfaceOp":{"op":"replace","start":4,"end":4}} -{"type":"user/message","seq":19,"time":1785762637756,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"5417d355-11a7-4d9a-b724-f63acf215392"},"surfaceOp":"append"} +{"type":"user/message","seq":19,"time":1785762637756,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"5417d355-11a7-4d9a-b724-f63acf215392"},"surfaceOp":"append"} {"type":"step/start","seq":20,"time":1785762637756,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":22,"time":1785464650886,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} From 46db6088436d948aaee311f68151aa5d2d095fe1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 4 Aug 2026 23:42:35 +0800 Subject: [PATCH 029/179] Align Codex provider review evidence --- packages/subagent/subagent-acp/src/run.ts | 44 +++--------- .../subagent-acp/tests/subagent-acp.spec.ts | 68 +------------------ .../subagent-codex/tests/real-product.spec.ts | 6 +- vitest.config.ts | 1 - 4 files changed, 13 insertions(+), 106 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index f264261b0e..fba0403739 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -90,41 +90,15 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -function scaledFiniteMilliseconds(ms: number, scale: number): bigint { - const whole = Math.floor(ms) - return BigInt(whole) * BigInt(scale) - + BigInt(Math.ceil((ms - whole) * scale)) -} - -/** - * Bounded whole-tree exit wait across Node-safe timer segments. - * @param child - process tree whose liveness is authoritative. - * @param ms - positive finite base window in milliseconds. - * @param scale - integer multiplier applied without Number overflow. - */ -async function treeExitsWithin( - child: SubprocessHandle, - ms: number, - scale = 1, -): Promise { - let remaining = scaledFiniteMilliseconds(ms, scale) - while (remaining > 0n) { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - const controller = new AbortController() - const timer = setTimeout(() => { controller.abort() }, Number(chunk)) - try { - if (await child.waitForExit(controller.signal)) return true - } finally { - clearTimeout(timer) - } +/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ +async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, ms) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) } - return false } /** @@ -151,7 +125,7 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe // (this plugin passes disposeGraceMs there), so the bound covers both the // escalation window and an equal confirmation window after the SIGKILL. child.terminate() - if (!(await treeExitsWithin(child, graceMs, 2))) { + if (!(await treeExitsWithin(child, graceMs * 2))) { throw new Error('ACP child process tree did not exit within its dispose windows') } } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 1a8bc577c1..f2cbeda27b 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' @@ -190,72 +190,6 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) }) - it('keeps an oversized finite escalation window instead of collapsing it to one millisecond', async () => { - vi.useFakeTimers() - try { - let waitCount = 0 - let reportExited!: (exited: boolean) => void - const terminate = vi.fn() - const waitForExit = vi.fn((signal?: AbortSignal) => { - waitCount += 1 - return new Promise((resolve) => { - signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) - if (waitCount === 2) reportExited = resolve - }) - }) - const child: Parameters[0] = { - pid: 1, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: {}, - done: new Promise(() => {}), - terminate, - waitForExit, - } - const disposal = disposeAcpChild(child, 0.25, Number.MAX_VALUE) - await vi.advanceTimersByTimeAsync(1) - expect(terminate).toHaveBeenCalledOnce() - expect(waitForExit).toHaveBeenCalledTimes(2) - const escalationSignal = waitForExit.mock.calls[1]?.[0] - await vi.advanceTimersByTimeAsync(1) - expect(escalationSignal?.aborted).toBe(false) - reportExited(true) - await expect(disposal).resolves.toBeUndefined() - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('chains a doubled grace beyond one Node timer segment', async () => { - vi.useFakeTimers() - try { - const waitForExit = vi.fn((signal?: AbortSignal) => new Promise((resolve) => { - signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) - })) - const child: Parameters[0] = { - pid: 1, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: {}, - done: new Promise(() => {}), - terminate: vi.fn(), - waitForExit, - } - const disposal = disposeAcpChild(child, 0.25, 1_073_741_823.75) - const rejected = expect(disposal).rejects.toThrow(/did not exit within its dispose windows/) - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(2_147_483_647) - expect(waitForExit).toHaveBeenCalledTimes(3) - await vi.advanceTimersByTimeAsync(1) - await rejected - } finally { - vi.useRealTimers() - } - }) - it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ argv: ['bash', '-c', 'true'], diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index dd7c0c458f..5f73adaf7e 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -170,7 +170,7 @@ describe('real @openai/codex 0.146.0 product', () => { expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key') expect(responseInputTexts(recorded.body)).toContain(task) await expectQuiescent(harness.handles) - }, 20_000) + }, 60_000) it('cancels a real app-server command approval without executing the command', async () => { const { harness, fixture } = await realHarness([ @@ -206,7 +206,7 @@ describe('real @openai/codex 0.146.0 product', () => { requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', )).toBe(true) await expectQuiescent(harness.handles) - }, 20_000) + }, 60_000) it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { const { harness, fixture } = await realHarness([{ kind: 'hold' }]) @@ -221,5 +221,5 @@ describe('real @openai/codex 0.146.0 product', () => { await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) await run.dispose() await expectQuiescent(harness.handles) - }, 20_000) + }, 60_000) }) diff --git a/vitest.config.ts b/vitest.config.ts index eac84d8d20..ddf7741716 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -54,7 +54,6 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', - 'packages/subagent/subagent-codex/tests/real-product.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', From 34b6cb91eda44b9febd928eaba192aecec63b909 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 00:26:39 +0800 Subject: [PATCH 030/179] Keep provider adapters private --- .../subagent-claude-code/src/process.ts | 7 ++----- .../subagent/subagent-claude-code/src/run.ts | 21 ++++++++++++------- .../tests/subagent-claude-code.spec.ts | 11 +--------- packages/subagent/subagent-codex/src/run.ts | 12 +++++++---- .../subagent/subagent/src/out-of-process.ts | 10 +++------ 5 files changed, 28 insertions(+), 33 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index b8b09216fa..e27e0a6649 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -76,11 +76,8 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { * @param child - shared handle that remains the process-tree authority. */ constructor(private readonly child: SubprocessHandle) { - if (child.stdin === undefined || child.stdout === undefined) { - throw new Error('subagent-claude-code: SDK child requires piped stdin and stdout') - } - this.stdin = child.stdin - this.stdout = child.stdout + this.stdin = child.stdin as NonNullable + this.stdout = child.stdout as NonNullable // EventEmitter gives `error` special throw semantics without a listener. // The SDK attaches its listener synchronously after custom spawn returns, // while this no-op also contains an already-rejected spawn handle. diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 2ad305f155..d5f222b6c4 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -20,7 +20,6 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { settleRunResult, subprocessRunHandle, - thrownError, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -39,6 +38,8 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/* jscpd:ignore-start -- sibling providers intentionally keep product-private + * run inputs and error normalization instead of adding a shared lifecycle owner. */ /** Fully resolved inputs for one official Claude Agent SDK query. */ export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ @@ -53,6 +54,12 @@ export interface ClaudeCodeRunSpec { readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } +function thrown(value: unknown): Error { + /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} +/* jscpd:ignore-end */ + /** * Validate and preserve the one-shot task before crossing the SDK boundary. * @param prompt - task content accepted from the shared subagent service. @@ -131,7 +138,7 @@ export async function disposeClaudeCodeChild( try { query?.close() } catch (error: unknown) { - failures.push(thrownError(error)) + failures.push(thrown(error)) } if (child.pid > 0) { @@ -139,13 +146,13 @@ export async function disposeClaudeCodeChild( try { await child.waitForExit() } catch (error: unknown) { - failures.push(thrownError(error)) + failures.push(thrown(error)) } } try { await child.done } catch (error: unknown) { - failures.push(thrownError(error)) + failures.push(thrown(error)) } const firstFailure = failures[0] @@ -234,7 +241,7 @@ export async function startClaudeCodeRun( await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { throw new AggregateError( - [thrownError(error), thrownError(disposeError)], + [thrown(error), thrown(disposeError)], 'subagent-claude-code: startup failed and CLI cleanup also failed', ) } @@ -243,7 +250,7 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { throw new AggregateError( - [thrownError(error), thrownError(disposeError)], + [thrown(error), thrown(disposeError)], 'subagent-claude-code: startup failed and query cleanup also failed', ) } @@ -252,7 +259,7 @@ export async function startClaudeCodeRun( if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrownError(error) + throw thrown(error) } const publishedQuery = query diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 8c8158fb28..0fa781c082 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -445,7 +445,7 @@ describe('official spawn projection', () => { expect(process.kill('SIGTERM')).toBe(false) }) - it('emits spawn errors and rejects handles without the required pipes', async () => { + it('emits spawn errors', async () => { const child = fakeChild() const process = new ManagedClaudeCodeProcess(child.handle) const errorListener = vi.fn() @@ -459,15 +459,6 @@ describe('official spawn projection', () => { message: 'spawn boom', })) expect(removed).not.toHaveBeenCalled() - - const missingStdin = fakeChild({ stdin: undefined }) - Object.defineProperty(missingStdin.handle, 'stdin', { value: undefined }) - expect(() => new ManagedClaudeCodeProcess(missingStdin.handle)) - .toThrow('requires piped stdin and stdout') - const missingStdout = fakeChild({ stdout: undefined }) - Object.defineProperty(missingStdout.handle, 'stdout', { value: undefined }) - expect(() => new ManagedClaudeCodeProcess(missingStdout.handle)) - .toThrow('requires piped stdin and stdout') }) it('exposes a settled direct-child exit code', async () => { diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 54a2bc1d79..9f52e18f12 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -13,7 +13,6 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { settleRunResult, subprocessRunHandle, - thrownError, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -39,6 +38,11 @@ export interface CodexRunSpec { readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } +function thrown(value: unknown): Error { + /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + /** * Validate and preserve the one-shot task before crossing the process seam. * @param prompt - task content accepted from the shared subagent service. @@ -120,7 +124,7 @@ export async function startCodexRun( 'subagent-codex: app-server exited before the run settled ' + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, )), - (error: unknown) => Promise.reject(thrownError(error)), + (error: unknown) => Promise.reject(thrown(error)), ) // A normal post-result dispose also closes the process. Keep that expected // late rejection observed after the result race has already settled. @@ -145,14 +149,14 @@ export async function startCodexRun( await disposeProcess() } catch (disposeError: unknown) { throw new AggregateError( - [thrownError(error), thrownError(disposeError)], + [thrown(error), thrown(disposeError)], 'subagent-codex: startup failed and app-server cleanup also failed', ) } if (runAbort.signal.aborted) { throw new Error('subagent-codex: request was aborted before run publication') } - throw thrownError(error) + throw thrown(error) } const collectOutput = (): ContentBlock[] => wire.collectOutput() diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index eac2125897..d049dba2be 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -119,12 +119,8 @@ export function resolveChildCwd(prefix: string, configured: string | undefined, return assertUsableCwd(prefix, 'parent session cwd', parentCwd) } -/** - * Normalize an unknown thrown value to an Error. - * @param value - the unknown catch binding. - * @returns the original Error or a defensive Error wrapper. - */ -export function thrownError(value: unknown): Error { +/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */ +function toError(value: unknown): Error { // The rejecting surfaces (wire clients, spawn failures) only throw // `Error`s; the `String(value)` arm is a defensive fallback for a non-Error // throw the typed surfaces cannot produce. @@ -168,7 +164,7 @@ export async function settleRunResult(parts: RunResultSettlement): Promise Date: Wed, 5 Aug 2026 01:07:58 +0800 Subject: [PATCH 031/179] Preserve Claude SDK child environment --- .../core-data-structures/subprocess.i18n.yaml | 6 ++-- docs/core-data-structures/subprocess.md | 13 ++++----- docs/core-data-structures/subprocess.zh.md | 13 ++++----- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../subagent-claude-code/src/process.ts | 28 +++++++++---------- .../tests/subagent-claude-code.spec.ts | 20 ++++++++++--- .../subprocess/subprocess-local/src/spawn.ts | 8 +++--- .../subprocess-local/tests/spawn.spec.ts | 13 +++++++++ .../subprocess/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/types.ts | 11 ++++---- 12 files changed, 72 insertions(+), 50 deletions(-) diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index b85701557b..39e7915bea 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.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 -subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2 -subprocess.zh.md: 5befdcdfc9b0e1d2a9adc825b177c90e53269def +# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md +subprocess.md: a97e407290aa12881e7d6e847d51fec13c134135 +subprocess.zh.md: e0b6f3fc2249d7095b5e540f91a0f13b544db978 diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 922e7ad0ee..a97e407290 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -8,7 +8,7 @@ Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subproces ## Managed environment namespace and captured output -`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate entry, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate string entry, while an explicit `undefined` tombstone removes an ordinary ambient value. Each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -115,13 +115,12 @@ interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } ``` diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 5befdcdfc9..e0b6f3fc22 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -8,7 +8,7 @@ ## 受管环境命名空间与捕获的输出 -`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的条目形式到达,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的字符串条目形式到达,而显式的 `undefined` tombstone 会删除普通环境中已有的值。每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -115,13 +115,12 @@ interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5f9af11105..2c62887faf 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2787,7 +2787,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessSpawnSpec', - declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n}', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: NodeJS.ProcessEnv | undefined;\n}', }, { name: 'SubprocessStdinMode', diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index e27e0a6649..32a545bf08 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -10,9 +10,10 @@ import type { SpawnedProcess, SpawnOptions, } from '@anthropic-ai/claude-agent-sdk' -import type { - SubprocessHandle, - SubprocessSpawnSpec, +import { + scrubbedParentEnv, + type SubprocessHandle, + type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' function thrown(value: unknown): Error { @@ -21,19 +22,18 @@ function thrown(value: unknown): Error { } /** - * Convert the SDK environment to the shared subprocess seam's defined-value - * overlay without changing the effective child environment. - * @param env - SDK-composed child environment. - * @returns entries whose values survive Node's subprocess environment. + * Encode the SDK's complete child environment as a subprocess overlay. + * @param env - SDK-composed child environment after its removals and replacements. + * @returns explicit values plus tombstones for surviving ambient names the SDK removed. */ -export function definedEnvironment( +export function sdkEnvironmentOverlay( env: SpawnOptions['env'], -): Record { - const defined: Record = {} - for (const [name, value] of Object.entries(env)) { - if (value !== undefined) defined[name] = value +): NodeJS.ProcessEnv { + const overlay: NodeJS.ProcessEnv = { ...env } + for (const name of Object.keys(scrubbedParentEnv())) { + if (!(name in env)) overlay[name] = undefined } - return defined + return overlay } /** @@ -55,7 +55,7 @@ export function claudeSpawnSpec( stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env: definedEnvironment(options.env), + env: sdkEnvironmentOverlay(options.env), } } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 0fa781c082..3dab6ab5cd 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -31,8 +31,8 @@ import * as claudeCode from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { claudeSpawnSpec, - definedEnvironment, ManagedClaudeCodeProcess, + sdkEnvironmentOverlay, } from '../src/process.ts' import { claudeQueryOptions, @@ -386,6 +386,7 @@ describe('task admission and package contracts', () => { describe('official spawn projection', () => { it('forwards command, arguments, cwd, environment, and signal exactly', () => { + vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value') const signal = new AbortController().signal const options = sdkSpawnOptions({ command: '/official/claude', @@ -394,15 +395,26 @@ describe('official spawn projection', () => { env: { A: 'one', B: undefined, C: 'three' }, signal, }) - expect(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' }) - expect(claudeSpawnSpec(options, 321)).toEqual({ + expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({ + A: 'one', + B: undefined, + C: 'three', + SDK_REMOVED_AMBIENT: undefined, + })) + const spawnSpec = claudeSpawnSpec(options, 321) + expect(spawnSpec).toMatchObject({ argv: ['/official/claude', '--one', 'two'], cwd: '/parent/workspace', stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: 321, signal, - env: { A: 'one', C: 'three' }, }) + expect(spawnSpec.env).toEqual(expect.objectContaining({ + A: 'one', + B: undefined, + C: 'three', + SDK_REMOVED_AMBIENT: undefined, + })) const missingCwd = sdkSpawnOptions() delete missingCwd.cwd expect(() => claudeSpawnSpec( diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 932daa2c59..d3cad162fb 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -26,12 +26,12 @@ import type { /** * Build a child environment: explicit caller entries merge after the scrubbed - * parent base, so a deliberately supplied credential or current `DSH_*` fact - * wins over the scrub that dropped its ambient namesake. - * @param extra - explicit caller entries, merged verbatim after the scrub. + * parent base. A string deliberately restores or overrides an entry; an + * explicit `undefined` tombstone removes an ordinary ambient entry. + * @param extra - explicit caller entries and tombstones, merged after the scrub. * @returns the environment to hand to `spawn` for the child process. */ -export function childEnv(extra?: Readonly>): NodeJS.ProcessEnv { +export function childEnv(extra?: Readonly): NodeJS.ProcessEnv { return { ...scrubbedParentEnv(), ...extra } } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ad2fc0f30a..224476b8d5 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -343,6 +343,19 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(result.stdout.text).toBe('alpha/beta\n') }) + it('lets an explicit tombstone remove an ordinary ambient env entry', async () => { + process.env.SUBPROCESS_TOMBSTONE_PROBE = 'ambient-value' + try { + const result = await finish(spawnSubprocess(spec( + 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"', + { env: { SUBPROCESS_TOMBSTONE_PROBE: undefined } }, + ))) + expect(result.stdout.text).toBe('absent\n') + } finally { + delete process.env.SUBPROCESS_TOMBSTONE_PROBE + } + }) + it('an explicit extra env entry overrides the credential scrub', async () => { // EXPLICIT_OVERRIDE_PASSWORD matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 64f64d65ed..a5e47747f0 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/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/subprocess/subprocess/README.md -README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260 -README.zh.md: dac459a6ed1b92c2354bf0a2cc4e0c23e824154f +README.md: 13c634429bfae9408dc732aea69df673e5da87aa +README.zh.md: fe7b28d3a8f256e0eb9b4cbb98093bac33816fdf diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index c360437bf2..13c634429b 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a string deliberately forwards or overrides a value, while an `undefined` tombstone removes an ordinary ambient entry. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index dac459a6ed..fe7b28d3a8 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清理后合并且不做命名空间校验——字符串会有意转发或覆盖某个值,而 `undefined` tombstone 则会删除普通的环境条目。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)会导入该环境清理定义。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 参见[子进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与[seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index fdfc44b3c2..a21f6ffb0d 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -94,13 +94,12 @@ export interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } /** From 01704b9a3ab7008664be6599e003e4eae499dae1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 01:30:42 +0800 Subject: [PATCH 032/179] Add Codex DeepSeek credentialed e2e --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 11 +- ...ude-code-and-codex-subagent-backends.zh.md | 11 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 4 +- packages/subagent/subagent-codex/README.zh.md | 4 +- .../tests/deepseek-responses-bridge.ts | 190 ++++++++++++++++++ .../subagent-codex/tests/real-deepseek.e2e.ts | 141 +++++++++++++ .../subagent-codex/tests/responses-fixture.ts | 9 +- 9 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts create mode 100644 packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index bde3f3cf11..9331bac1a3 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 3b9fd51632439da5b3c3fd9187de552d6c9ca5e2 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 36be903640ad1c839d45ed1bf5e605f4e4d6e000 +2026-08-04-claude-code-and-codex-subagent-backends.md: 1908eb3466fd6ae6cd14f74e70366d2c7b4c977f +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 6d173133a308be2613cc71b99c5dfd695d18f337 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 3b9fd51632..1908eb3466 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -8,7 +8,7 @@ English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official protocol, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot replace the former; a hand-mounted plugin cannot replace the latter. +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required evidence therefore separates three facts: a keyless real-product test proves the official protocol, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. ## Proposal @@ -45,16 +45,20 @@ For command and file approvals, the unattended wire selects a non-approval decis An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. +Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. + ## Claude Code provider The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented. ## Evidence contract -Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. +Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Its Loader e2e resolves `@deepseek-ai/dsh-subagent-codex` by package name, verifies the `codex` registration and `subagent_codex` schema with background omitted, accepts `maxDepth: 'provider-managed'`, and records zero child starts while no `codex` command is available. The npm package is a development dependency for reproducible real-product evidence; production still supplies `codex` on `PATH`. +The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. + The combined contract is complete only when the Claude sibling has equivalent real-product evidence and both public Loader configurations prove the fixed tools use the unchanged common subagent contract. ## Alternatives considered @@ -73,7 +77,7 @@ The combined contract is complete only when the Claude sibling has equivalent re ## Acceptance criteria -Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required real-product specs return exact final answers or explicit failure or cancellation and prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. +Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required keyless real-product specs return exact final answers or explicit failure or cancellation, and separate credentialed e2e tests traverse each production provider and real product to a unique DeepSeek answer; both tiers prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. @@ -81,6 +85,7 @@ The implemented Codex half satisfies this contract for its fixed tool and 0.146. - The product protocols are versioned and may change. Production performs no runtime version probe, so every supported baseline change requires refreshed compatibility evidence. - Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's authority. +- Credentialed e2e runs spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. - Every delegation pays for a fresh process and independent model context, and only final text reaches the parent. - Product tool or file side effects are not rolled back when a run fails or is cancelled. - Unattended interaction denial prevents hidden approval hangs but cannot satisfy tasks that require new permission or human input. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 36be903640..6d173133a3 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -8,7 +8,7 @@ Status: proposed 命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 -产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据会分别证明两个事实:真实产品测试证明官方协议、原生身份验证形态、最终回答和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品。直接发起模型 HTTP 请求或使用产品替身无法取代前者,手工挂载插件则无法取代后者。 +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性证据会分别证明三个事实:无密钥真实产品测试证明官方协议、原生身份验证形态、确定性答案和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务获得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代任一产品运行层级,手工挂载插件则无法取代 Loader 层级。 ## 提案 @@ -45,16 +45,20 @@ fixed tool → shared subagent service → product provider → official product 若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 +Codex 0.146.0 使用 Responses 协议,而 DeepSeek 公开的 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会使用一个仅限回环、仅供测试内部使用的桥接层来完成一次不使用工具的随机数请求:真实 Codex 向该桥接层发送 Responses 请求,桥接层将收到的 Bearer 凭证与提取出的任务转发到固定的 DeepSeek 官方端点,并将真实文本封装进最小的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能证明 Codex 原生连接 DeepSeek Chat Completions。 + ## Claude Code 提供方 Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界,本 Agent Note 才能进入 implemented 状态。 ## 证据契约 -每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一项 Loader 装配 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。 +每个产品都负责覆盖所有分支的包(package)测试、一项必跑的无密钥真实产品测试、一项 Loader 装配 e2e 和一项带密钥的 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。带密钥层级使用仅在运行时提供的密钥启动相同的生产提供方与真实产品,要求从固定的 DeepSeek 官方服务获得一个唯一的随机数,并再次证明完全停稳;只有本地操作方未提供密钥时才会自行跳过,受信 CI 则会对该 secret 执行 preflight 检查。 Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader e2e 会按包名解析 `@deepseek-ai/dsh-subagent-codex`,验证 `codex` 注册与省略后台参数的 `subagent_codex` schema,接受 `maxDepth: 'provider-managed'`,并在环境中没有可用 `codex` 命令时记录零次子级启动。该 NPM 包是用于复现真实产品证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 带密钥 e2e 会注册生产提供方,启动同一个真实 app-server,并通过上文所述、仅供测试内部使用的桥接层请求一个随机数。它会固定外部端点和模型,不存储任何凭证或请求 payload,要求恰好有一个上游响应完成,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待每个受管句柄退出。 + 只有在 Claude 兄弟提供方具备同等的真实产品证据,并且两个公开 Loader 配置都证明固定工具使用未变的通用 subagent 契约时,组合契约才算完整。 ## 曾考虑的替代方案 @@ -73,7 +77,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 ## 验收标准 -两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,并证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 +两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制无密钥真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,独立的带密钥 e2e 测试则会贯穿每个生产提供方与真实产品,取得唯一的 DeepSeek 答案;两个层级都会证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 @@ -81,6 +85,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 - 产品协议受版本约束,且可能发生变化。生产环境不会执行运行时版本探测,因此每次更改受支持的基线都必须刷新兼容性证据。 - 产品原生配置使行为取决于部署环境中安装的产品与账户状态。强制测试会隔离这些输入,而生产环境会有意让产品继续负责它们。 +- 带密钥 e2e 运行会消耗外部 API 配额,并依赖 DeepSeek 官方端点;协议、失败、取消与审批的确定性覆盖仍由无密钥层级负责。 - 每次委派都要承担新建进程和独立模型上下文的开销,且只有最终文本会到达父级。 - 运行失败或被取消时,产品工具或文件产生的副作用不会回滚。 - 拒绝无人值守交互可以防止审批流程暗中挂起,但无法完成需要新权限或人工输入的任务。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index c3d4da77bf..a40167b79a 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent-codex/README.md -README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8 -README.zh.md: 110953312162e146f01ef037a40d2f70b136850c +README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece +README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index d7293a0ef3..1dde57e10e 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -47,7 +47,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. ## Model Experience @@ -83,7 +83,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. -- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, and real-product tests. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. - **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 1109533121..cb56281d90 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -47,7 +47,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex,再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 ## 模型体验 @@ -83,7 +83,7 @@ Codex 子任务会在一个全新的临时线程中,以单个轮次接收这 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 -- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消和真实产品测试。 +- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 - **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts b/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts new file mode 100644 index 0000000000..b59738031e --- /dev/null +++ b/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts @@ -0,0 +1,190 @@ +import { createServer } from 'node:http' +import type { + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' +import { completeResponsesEvents } from './responses-fixture.ts' + +const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com' +const MAX_REQUEST_BYTES = 1_048_576 + +/** One running test-only Responses-to-DeepSeek bridge. */ +export interface DeepSeekResponsesBridge { + readonly baseUrl: string + readonly completedRequests: number + close(): Promise +} + +function readRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { + body += chunk + if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) { + request.destroy(new Error('DeepSeek bridge request exceeded its byte limit')) + } + }) + request.on('end', () => { resolve(body) }) + request.on('error', reject) + }) +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +function taskText(body: Record): string { + const input = responseInputTexts(body).join('\n') + if (input.trim().length > 0) return input + return typeof body.instructions === 'string' ? body.instructions : '' +} + +function deepSeekBaseUrl(): string { + const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL) + .replace(/\/+$/, '') + if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) { + throw new Error('Codex DeepSeek e2e requires the official DeepSeek base URL') + } + return configured +} + +async function completeWithDeepSeek( + authorization: string, + task: string, +): Promise { + const response = await fetch(`${deepSeekBaseUrl()}/chat/completions`, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-v4-flash', + messages: [ + { + role: 'system', + content: 'Follow the user instruction and return only the requested nonce.', + }, + { role: 'user', content: task }, + ], + temperature: 0, + max_tokens: 64, + stream: false, + }), + }) + if (!response.ok) { + void response.body?.cancel() + throw new Error(`DeepSeek bridge upstream returned HTTP ${response.status}`) + } + const payload = await response.json() as { + choices?: Array<{ message?: { content?: unknown } }> + } + const content = payload.choices?.[0]?.message?.content + if (typeof content !== 'string' || content.trim().length === 0) { + throw new Error('DeepSeek bridge upstream returned no text') + } + return content +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + server.closeAllConnections() + }) +} + +/** + * Start the single-purpose loopback bridge used by the Codex credentialed e2e. + * @param nonce - unique answer the incoming Responses task must request. + * @returns loopback endpoint, completion count, and close operation. + */ +export async function startDeepSeekResponsesBridge( + nonce: string, +): Promise { + let seenRequests = 0 + let completedRequests = 0 + const openResponses = new Set() + const server = createServer((request, response) => { + openResponses.add(response) + response.on('close', () => { openResponses.delete(response) }) + void (async () => { + if (request.method !== 'POST' || request.url !== '/v1/responses') { + response.writeHead(404) + response.end() + return + } + if (seenRequests !== 0) { + response.writeHead(409) + response.end() + return + } + seenRequests += 1 + const authorization = request.headers.authorization + if ( + typeof authorization !== 'string' + || !authorization.startsWith('Bearer ') + || authorization.length === 'Bearer '.length + ) { + throw new Error('Codex DeepSeek bridge received no bearer credential') + } + const body = JSON.parse(await readRequest(request)) as Record + const task = taskText(body) + if (!task.includes(nonce)) { + throw new Error('Codex DeepSeek bridge request omitted the expected nonce') + } + const text = await completeWithDeepSeek(authorization, task) + completedRequests += 1 + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'x-request-id': 'req_deepseek_e2e', + }) + for (const event of completeResponsesEvents(text)) { + response.write(`data: ${JSON.stringify(event)}\n\n`) + } + response.end('data: [DONE]\n\n') + })().catch(() => { + if (!response.headersSent) { + response.writeHead(502, { 'content-type': 'application/json' }) + } + response.end(JSON.stringify({ error: { message: 'DeepSeek bridge request failed' } })) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('DeepSeek bridge did not acquire a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + get completedRequests(): number { return completedRequests }, + async close(): Promise { + for (const response of openResponses) response.destroy() + await closeServer(server) + }, + } +} diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts new file mode 100644 index 0000000000..29c5536bc0 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -0,0 +1,141 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import { + startDeepSeekResponsesBridge, + type DeepSeekResponsesBridge, +} from './deepseek-responses-bridge.ts' + +const execFileAsync = promisify(execFile) +const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackage = JSON.parse(readFileSync( + join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + 'utf8', +)) as { version: string } + +const roots: string[] = [] +const contexts: Context[] = [] +const bridges: DeepSeekResponsesBridge[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(bridges.splice(0).map(bridge => bridge.close())) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.done).resolves.toHaveProperty('exitCode') + } +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)( + 'Codex provider with real DeepSeek API', + () => { + it('returns one unique nonce through the production provider and real Codex', async () => { + const apiKey = process.env.DEEPSEEK_API_KEY + if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-deepseek-e2e-')) + roots.push(root) + const workspace = join(root, 'workspace') + const codexHome = join(root, 'codex-home') + mkdirSync(workspace) + mkdirSync(codexHome) + const nonce = `DSH_CODEX_DEEPSEEK_${randomUUID()}` + const bridge = await startDeepSeekResponsesBridge(nonce) + bridges.push(bridge) + writeFileSync(join(codexHome, 'config.toml'), [ + 'model = "deepseek-v4-flash"', + 'model_provider = "deepseek-e2e"', + 'approval_policy = "never"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.deepseek-e2e]', + 'name = "DeepSeek E2E bridge"', + `base_url = "${bridge.baseUrl}"`, + 'env_key = "DEEPSEEK_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + const env = { + DEEPSEEK_API_KEY: apiKey, + CODEX_HOME: codexHome, + HOME: root, + XDG_CONFIG_HOME: join(root, 'xdg-config'), + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + env: { ...process.env, ...env }, + }) + expect(codexPackage.version).toBe('0.146.0') + expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + + const parent = { + id: 'deepseek-e2e-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + const run = await ctx.subagents.start('codex', { + prompt: [{ + type: 'text', + text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`, + }], + parent, + signal: new AbortController().signal, + }) + const result = await run.result + await run.dispose() + + expect(result.stopReason).toBe('completed') + const text = result.output + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .trim() + expect(text).toBe(nonce) + expect(bridge.completedRequests).toBe(1) + await expectQuiescent(handles) + }, 180_000) + }, +) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index 940b0d52a0..cac49b9158 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -85,7 +85,12 @@ function responseObject(text: string): Record { } } -function completeEvents(text: string): Record[] { +/** + * Build the minimal Responses SSE event sequence consumed by Codex 0.146.0. + * @param text - exact assistant answer. + * @returns ordered response lifecycle events. + */ +export function completeResponsesEvents(text: string): Record[] { const completed = responseObject(text) const message = (completed.output as Record[])[0]! const part = (message.content as Record[])[0]! @@ -250,7 +255,7 @@ export async function startResponsesFixture( }) if (behavior.kind === 'hold') return const events = behavior.kind === 'complete' - ? completeEvents(behavior.text) + ? completeResponsesEvents(behavior.text) : functionCallEvents(behavior.name, behavior.arguments) for (const event of events) { response.write(`data: ${JSON.stringify(event)}\n\n`) From a49ef7581fbc15341e1c34bc7549d51981dda6c7 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 04:21:29 +0800 Subject: [PATCH 033/179] Fix Codex wire frames and bound grace timers --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- docs/config-catalog.md | 2 +- .../core-data-structures/subprocess.i18n.yaml | 6 +- docs/core-data-structures/subprocess.md | 9 +-- docs/core-data-structures/subprocess.zh.md | 9 +-- docs/module-graph.md | 6 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 2 +- packages/subagent/subagent-codex/README.zh.md | 2 +- packages/subagent/subagent-codex/package.json | 2 + packages/subagent/subagent-codex/src/index.ts | 6 ++ packages/subagent/subagent-codex/src/wire.ts | 31 +++------- .../tests/subagent-codex.spec.ts | 13 ++++ .../subagent/subagent-codex/tsconfig.json | 3 + .../subprocess/subprocess-local/package.json | 2 + .../subprocess/subprocess-local/src/spawn.ts | 60 ++++--------------- .../subprocess-local/tests/spawn.spec.ts | 45 +++----------- .../subprocess/subprocess-local/tsconfig.json | 3 + .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/types.ts | 9 +-- pnpm-lock.yaml | 6 ++ 25 files changed, 100 insertions(+), 136 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 9331bac1a3..0045a33382 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 1908eb3466fd6ae6cd14f74e70366d2c7b4c977f -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 6d173133a308be2613cc71b99c5dfd695d18f337 +2026-08-04-claude-code-and-codex-subagent-backends.md: 0afeae6269fcff588461dd58221c376a257c1f1b +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c754f5f436fd76b85bd15e45f9673d8bfe61cce6 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 1908eb3466..0afeae6269 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -35,7 +35,7 @@ fixed tool → shared subagent service → product provider → official product ## Codex provider -`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 6d173133a3..c754f5f436 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -35,7 +35,7 @@ fixed tool → shared subagent service → product provider → official product ## Codex 提供方 -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并始终启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2c1dd2a33a..85114bca5a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1580,7 +1580,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-codex/src/index.ts:29`](../packages/subagent/subagent-codex/src/index.ts) +Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) ## `@deepseek-ai/dsh-subagent-dsh-sdk` diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index b85701557b..98b8f06a56 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.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 -subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2 -subprocess.zh.md: 5befdcdfc9b0e1d2a9adc825b177c90e53269def +# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md +subprocess.md: 9e1a73e0b807347f6c87ab5a589b7bb4b763df0e +subprocess.zh.md: 769d59e6610ffe2114984797b4b84afafc472262 diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 922e7ad0ee..9e1a73e0b8 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -101,10 +101,11 @@ interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 5befdcdfc9..769d59e661 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -101,10 +101,11 @@ interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/docs/module-graph.md b/docs/module-graph.md index fc6cb07cb4..39693da6a7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -343,6 +343,7 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_credentials @@ -1009,6 +1010,7 @@ flowchart TD pkg_subagent_codex --> pkg_session pkg_subagent_codex --> pkg_subagent pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -1124,7 +1126,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1254,7 +1256,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index a40167b79a..97c2b9f705 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent-codex/README.md -README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece -README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f +README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f +README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 1dde57e10e..c25ee90edf 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index cb56281d90..10c8fcc47a 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -23,7 +23,7 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 10bf7ee5f4..cc1d016225 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@openai/codex": "0.146.0", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 00fe95d817..09ece5e22e 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { assertPositiveFinite, NO_START_CAPABILITIES, @@ -85,5 +86,10 @@ export function apply(ctx: Context, config: Config): void { 'disposeGraceMs', resolved.disposeGraceMs, ) + if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index f933c1a04b..460eeae877 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -14,22 +14,6 @@ import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' type JsonObject = Record -interface Deferred { - readonly promise: Promise - readonly resolve: (value: T) => void - readonly reject: (reason?: unknown) => void -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((settle, fail) => { - resolve = settle - reject = fail - }) - return { promise, resolve, reject } -} - function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) @@ -98,11 +82,11 @@ async function raceAbort(pending: Promise, signal: AbortSignal): Promise() + private readonly fatal = Promise.withResolvers() private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined - private turnCompleted: Deferred | undefined + private turnCompleted: PromiseWithResolvers | undefined private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject @@ -193,7 +177,7 @@ export class CodexAppServerWire { signal: AbortSignal, cancelled: () => boolean, ): Promise { - const completion = deferred() + const completion = Promise.withResolvers() this.turnCompleted = completion const threadId = this.threadId as string const response = object(await this.guarded(this.transport.request('turn/start', { @@ -340,7 +324,8 @@ export class CodexAppServerWire { private handleNotification(method: string, params: JsonObject): void { if (method === 'turn/started') { - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'turn/started thread id') + if (threadId !== this.threadId) return const turn = object(params.turn, 'turn/started turn') if (this.turnCompleted !== undefined && this.turnId === undefined) { this.observePendingTurnId(string(turn.id, 'turn/started turn id')) @@ -348,7 +333,8 @@ export class CodexAppServerWire { return } if (method === 'item/completed') { - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'item/completed thread id') + if (threadId !== this.threadId) return const id = string(params.turnId, 'item/completed turn id') if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { @@ -373,7 +359,8 @@ export class CodexAppServerWire { return } if (method !== 'turn/completed') return - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'turn/completed thread id') + if (threadId !== this.threadId) return const turn = object(params.turn, 'turn/completed turn') const id = string(turn.id, 'turn/completed turn id') const turnCompleted = this.turnCompleted diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 181ddb919e..01cc28e59e 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -6,6 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, SubprocessOutcome, @@ -294,6 +295,8 @@ describe('task admission and package contracts', () => { await expect(ctx.plugin(codex, { disposeGraceMs })) .rejects.toThrow('disposeGraceMs must be a positive finite number') } + await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) await ctx.fiber.dispose() }) @@ -513,6 +516,16 @@ describe('CodexAppServerWire', () => { } }) + it('fails closed when terminal notification params are not an object', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send({ method: 'turn/completed', params: null }) + await expect(result).rejects.toThrow('invalid turn/completed thread id') + wire.close() + }) + it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal, () => false) diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json index 6034bf5fbe..b9f33967ba 100644 --- a/packages/subagent/subagent-codex/tsconfig.json +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 72ff50c422..871b4cfac6 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -29,11 +29,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 932daa2c59..fda32be42a 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { CollectedOutput, SubprocessCollect, @@ -55,47 +56,6 @@ function sleepTick(): Promise { return sleepMs(15) } -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Schedule a positive finite millisecond delay across as many Node-safe timer - * segments as necessary. Fractional milliseconds round up so a grace never - * expires earlier than configured. - * @param delayMs - positive finite delay in milliseconds. - * @param callback - work to run after the complete delay. - * @returns a handle that cancels the active segment and all future segments. - */ -export function scheduleFiniteTimeout( - delayMs: number, - callback: () => void, -): { cancel(): void } { - let remaining = BigInt(Math.ceil(delayMs)) - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - callback() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - cancel(): void { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - let spillCounter = 0 let defaultSpillDir: string | undefined @@ -339,8 +299,12 @@ function signalTree( * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. * @param internals - test-only spill-directory, platform, and taskkill overrides. * @returns live subprocess handle. + * @throws when `graceMs` cannot be represented by one Node timer. */ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { + if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } const spillDir = internals.spillDir ?? privateSpillDir() const platform = internals.platform ?? process.platform const taskkill = internals.taskkill ?? taskkillProcessTree @@ -382,7 +346,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') const stderrCollector = collectStream(errMode, child.stderr, 'stderr') - let graceTimer: ReturnType | undefined + let graceTimer: ReturnType | undefined let treeExitObserved = false let treeExitObservation: Promise | undefined let settled = false @@ -426,7 +390,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter treeExitObservation ??= (async () => { while (treeAlive()) await sleepTick() treeExitObserved = true - graceTimer?.cancel() + if (graceTimer !== undefined) clearTimeout(graceTimer) graceTimer = undefined })() return treeExitObservation @@ -457,7 +421,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // kill() re-probes tree liveness before force-killing. It stays ref'd: // the pending SIGKILL is a commitment, and a parent exiting before it // fires would orphan a trapped survivor. Self-bounds at graceMs. - graceTimer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) + graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } // The caller owns timeout classification; this layer only reacts to abort. @@ -472,7 +436,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const done = new Promise((resolve, reject) => { - let pipeDrainTimer: ReturnType | undefined + let pipeDrainTimer: ReturnType | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return settled = true @@ -495,15 +459,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // A surviving descendant that inherited a pipe must not hold the // outcome open indefinitely: after exit, the same bounded grace that // governs kills also bounds the close wait. - pipeDrainTimer = scheduleFiniteTimeout(spec.graceMs, () => { + pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) - }) + }, spec.graceMs) }) child.on('close', settle) function cleanup(): void { // graceTimer deliberately NOT cleared: the SIGKILL escalation must be // able to reach tree survivors after the direct child settles. - pipeDrainTimer?.cancel() + if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) spec.signal?.removeEventListener('abort', onAbort) } }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ad2fc0f30a..34157f8fc0 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -5,11 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import { killGroup, OutputCollector, - scheduleFiniteTimeout, spawnSubprocess, taskkillProcessTree, } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -107,31 +107,15 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } -describe('scheduleFiniteTimeout', () => { - it('rounds fractions up, chains Node-safe segments, and cancels idempotently', async () => { - vi.useFakeTimers() - try { - const fired = vi.fn() - const chained = scheduleFiniteTimeout(2_147_483_647.25, fired) - await vi.advanceTimersByTimeAsync(2_147_483_647) - expect(fired).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(fired).toHaveBeenCalledOnce() - chained.cancel() - - const cancelled = vi.fn() - const timer = scheduleFiniteTimeout(0.25, cancelled) - timer.cancel() - timer.cancel() - await vi.advanceTimersByTimeAsync(1) - expect(cancelled).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) -}) - describe('spawnSubprocess', () => { + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1])( + 'rejects an invalid grace before spawning: %s', + (graceMs) => { + expect(() => spawnSubprocess(spec('true', { graceMs }))) + .toThrow(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + }, + ) + it('captures stdout on success', async () => { const result = await finish(spawnSubprocess(spec('echo hello'))) expect(result.exitCode).toBe(0) @@ -194,17 +178,6 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) - it('cancels a larger-than-Node escalation timer once SIGTERM removes the tree', async () => { - const running = spawnSubprocess(spec('echo ready; sleep 60', { - graceMs: Number.MAX_VALUE, - })) - await waitForStdout(running, 'ready\n') - running.terminate() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - await expect(running.waitForExit()).resolves.toBe(true) - }) - it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => { const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`) const graceMs = 160 diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5a8dea211b..5272a4f78d 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 64f64d65ed..dd8908dc7d 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/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/subprocess/subprocess/README.md -README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260 -README.zh.md: dac459a6ed1b92c2354bf0a2cc4e0c23e824154f +README.md: a8061e67a5346fce785f3c9fb27c8f885bf99921 +README.zh.md: 0e6a7e9192eb7028ea951681f5e013f8012a4121 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index c360437bf2..a8061e67a5 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -7,7 +7,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes ## Contract - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. -- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). Grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so the implementation can represent it with one Node timer instead of accepting a value that Node collapses to one millisecond. `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index dac459a6ed..0e6a7e9192 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -7,7 +7,7 @@ ## 契约 - `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 -- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 +- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样实现便可用一个 Node 定时器表示它,而不会接受会被 Node 折叠为 1 毫秒的值。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index fdfc44b3c2..3dbe401b75 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -80,10 +80,11 @@ export interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c10c897164..a817239885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5119,6 +5119,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@openai/codex': specifier: 0.146.0 version: 0.146.0 @@ -5460,6 +5463,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From 075b6b0756c2491e3f5f8bf9f4a7bddb67d4568a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 04:45:11 +0800 Subject: [PATCH 034/179] fix(subagent-codex): drop unused initialize metadata gate --- packages/subagent/subagent-codex/src/wire.ts | 3 +-- packages/subagent/subagent-codex/tests/subagent-codex.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 460eeae877..ca24fdcadf 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -130,7 +130,7 @@ export class CodexAppServerWire { * @param signal - unpublished-start cancellation. */ async initialize(signal: AbortSignal): Promise { - const response = object(await this.guarded(this.transport.request('initialize', { + object(await this.guarded(this.transport.request('initialize', { clientInfo: { name: 'deepseek-harness', title: 'DeepSeek Harness', @@ -141,7 +141,6 @@ export class CodexAppServerWire { requestAttestation: false, }, }, signal), signal), 'initialize response') - string(response.userAgent, 'initialize userAgent') this.transport.notify('initialized') await this.guarded(this.transport.flush(), signal) } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 01cc28e59e..52d2f558c3 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -934,8 +934,8 @@ describe('run lifecycle and quiescence', () => { const child = fakeChild() const starting = startCodexRun(request(), runSpec(child)) const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: '' }) - await expect(starting).rejects.toThrow('initialize userAgent') + child.peer.respond(initialize, null) + await expect(starting).rejects.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) }) From 0ccd847ef30cd9c09d6179e1eaf58e7113c4d1db Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 05:24:23 +0800 Subject: [PATCH 035/179] Fix timer bounds and ACP teardown ownership --- ...07-27-dispose-ladder-to-consumer.i18n.yaml | 6 +-- .../2026-07-27-dispose-ladder-to-consumer.md | 4 +- ...026-07-27-dispose-ladder-to-consumer.zh.md | 4 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- docs/capability-seams.md | 5 +-- docs/config-catalog.md | 15 +++---- packages/bash/bash-local/README.i18n.yaml | 4 +- packages/bash/bash-local/README.md | 2 +- packages/bash/bash-local/README.zh.md | 2 +- packages/bash/bash-local/src/index.ts | 7 +++- .../bash/bash-local/tests/executor.spec.ts | 3 ++ packages/bash/pwsh-local/README.i18n.yaml | 4 +- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/pwsh-local/src/index.ts | 7 +++- .../bash/pwsh-local/tests/executor.spec.ts | 3 ++ packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 2 +- packages/fs/tool-fs-search/README.zh.md | 2 +- packages/fs/tool-fs-search/package.json | 2 + packages/fs/tool-fs-search/src/index.ts | 6 ++- .../fs/tool-fs-search/tests/tools.spec.ts | 12 ++++++ packages/fs/tool-fs-search/tsconfig.json | 3 ++ .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 8 ++-- packages/subagent/subagent-acp/README.zh.md | 8 ++-- packages/subagent/subagent-acp/package.json | 2 + packages/subagent/subagent-acp/src/index.ts | 12 +++--- packages/subagent/subagent-acp/src/run.ts | 25 +++++------ .../subagent-acp/tests/subagent-acp.spec.ts | 41 +++++++------------ packages/subagent/subagent-acp/tsconfig.json | 3 ++ packages/subagent/subagent-codex/package.json | 1 + pnpm-lock.yaml | 9 ++++ scripts/gen-doc-graphs.ts | 4 +- 36 files changed, 130 insertions(+), 96 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml index bd5964f1a4..ec9558da89 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.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-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 -2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +2026-07-27-dispose-ladder-to-consumer.md: e9af88e8e7ef962213a74e96a249241cbe8d5994 +2026-07-27-dispose-ladder-to-consumer.zh.md: ed242c4f9fd3b44311e6aae25c176617cdf9e897 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md index 97b551ff50..e9af88e8e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -10,7 +10,7 @@ English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) ## Decision -The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call `terminate()`, whose SIGTERM→spec-grace→SIGKILL escalation already owns the signal timer, and await an unbounded `waitForExit()` for the subprocess owner's whole-tree exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real tree exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. ## Alternatives considered @@ -20,4 +20,4 @@ The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(c ## Consequences -Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy. +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns the termination window and final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false before escalation and an unbounded whole-tree join after it) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md index b6849ad393..ed242c4f9f 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已拥有信号定时器),再无界等待 `waitForExit()`,由子进程责任方证明整棵进程树已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认进程树真正退出所需的停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 ## 曾考虑的替代方案 @@ -20,4 +20,4 @@ Status: implemented ## 后果 -买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止时间窗与最终的整树退出等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 返回假,升级后无界等待整棵进程树退出),而非组合后的策略。 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 0045a33382..d63b100325 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 0afeae6269fcff588461dd58221c376a257c1f1b -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c754f5f436fd76b85bd15e45f9673d8bfe61cce6 +2026-08-04-claude-code-and-codex-subagent-backends.md: fc5e8b6dc5a109fe325530646348ccffaf5458ac +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f68c487ee8494908e5e7748b5881a888af688c82 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 0afeae6269..fc5e8b6dc5 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -39,7 +39,7 @@ fixed tool → shared subagent service → product provider → official product Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index c754f5f436..f68c487ee8 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -39,7 +39,7 @@ fixed tool → shared subagent service → product provider → official product 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index cc32e9057e..bb19ff7cb8 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -104,7 +104,6 @@ flowchart LR pkg_lsp_local["lsp-local"] pkg_subagent_acp["subagent-acp"] pkg_subagent_codex["subagent-codex"] - pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] pkg_pwsh_local["pwsh-local"] @@ -137,6 +136,7 @@ flowchart LR svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] + pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_tool_subagent_control["tool-subagent-control"] pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] @@ -323,7 +323,6 @@ flowchart LR svc_subprocess --> pkg_lsp_local svc_subprocess --> pkg_subagent_acp svc_subprocess --> pkg_subagent_codex - svc_subprocess --> pkg_subagent_dsh_sdk svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -383,7 +382,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex) | - | The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`pwsh-local`](../packages/bash/pwsh-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`bash-env`](../packages/bash/bash-env) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 85114bca5a..ac181d8d78 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -223,7 +223,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } ``` @@ -992,7 +992,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -1550,10 +1550,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent escalates to a signal. + * before the parent escalates to a signal. Must not exceed + * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Termination confirmation window (ms), including forced exit on every platform. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -1561,7 +1562,7 @@ export interface Config { export type PermissionPolicy = 'allow' | 'reject' ``` -Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) +Source: [`packages/subagent/subagent-acp/src/index.ts:27`](../packages/subagent/subagent-acp/src/index.ts) ## `@deepseek-ai/dsh-subagent-codex` @@ -1814,7 +1815,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number @@ -1823,7 +1824,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:72`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:73`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index e72432de87..49d3af2a85 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/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/bash/bash-local/README.md -README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 -README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17 +README.md: 9b01f1ce9d046062e55037ba13e8f74b95ef1161 +README.zh.md: fde3eb77f72337d54c91c1a40fb87cb3d13652e7 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 694b7a7686..9b01f1ce9d 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -25,7 +25,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). - **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index c56543f269..fde3eb77f7 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -25,7 +25,7 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: - **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。 -- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 - **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 0f5a1b4e4d..09d21f11d0 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -13,7 +13,7 @@ import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' /** * Model-friendly environment overrides: disable colors, pagers, and @@ -47,7 +47,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } @@ -101,6 +101,9 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) + if (this.config.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + } } /** diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 1395b48958..af5566086f 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) @@ -70,6 +71,8 @@ describe('LocalBashExecutor.run', () => { await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) + await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) const { bash } = await setup() expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 1b78ca75f9..d4ac605341 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/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/bash/pwsh-local/README.md -README.md: 9deba9c1b63ccfdb9e1805b9896db33f144839bf -README.zh.md: e45c820e1d5e31aebd9ed365c6130850f1db2a62 +README.md: 35ff7dba97b8109a99e649051166604fb279c408 +README.zh.md: fdba5baf00b50ce5384d9209c8fa01f42642491d diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index 9deba9c1b6..35ff7dba97 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. - **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. - **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index e45c820e1d..fdba5baf00 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -30,7 +30,7 @@ - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 - **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。 -- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 - **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 316d2c8651..20ecffd969 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -18,7 +18,7 @@ import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolvePwshPath } from './resolve.ts' /* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */ @@ -62,7 +62,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -129,6 +129,9 @@ export class PwshLocalExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) + if (this.config.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + } this.pwshPath = resolvePwshPath(this.config.pwshPath) } diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index 4552f2eeec..c7da16c44c 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -19,6 +19,7 @@ import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPa import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SubprocessService from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) @@ -187,6 +188,8 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) + await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) const { bash } = await setup() expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index a8e2222998..374c5e4f05 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/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/fs/tool-fs-search/README.md -README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a -README.zh.md: 42b123d5c47d8f48bc21b6f9bed4905372ca8625 +README.md: 32fa61e3bb09b2166499003953a5631a93baf73b +README.zh.md: 766ac01dadde34ef1f9ef9b9f6abfb719ba3d0a6 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 78ffa069e5..32fa61e3bb 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -30,7 +30,7 @@ The binary ships with the package on every supported platform (macOS/Linux/Windo | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | | `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | -| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. | +| `graceMs` | `3000` | Positive terminate-escalation grace the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | | `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). | ## Tools diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 42b123d5c4..766ac01dad 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -30,7 +30,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | | `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | -| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 | +| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期须为正值;超过后搜索以 `SEARCH_ABORTED` 失败;该宽限期不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | | `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 | ## 工具 diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 8953aea77a..1884ae913c 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-spill": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -51,6 +52,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 7f8e43cb73..cf0a8db066 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -28,6 +28,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' @@ -82,7 +83,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number @@ -130,6 +131,9 @@ export async function apply(ctx: Context, config: Config): Promise { assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) assertPositiveInteger('graceMs', resolved.graceMs) + if (resolved.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + } assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) applyGlobTool(ctx, { diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 9a8fa991ff..e9ae5588ee 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -18,6 +18,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecution, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { rgPath } from '@vscode/ripgrep' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' @@ -309,6 +310,17 @@ describe('config validation', () => { await ctx.plugin(FakeSubprocess) await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`)) }) + + it('rejects a grace beyond the Node timer range at load', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeSubprocess) + await expect(ctx.plugin(ToolFsSearch, { + ...DEFAULT_CONFIG, + graceMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + }) }) describe('command construction (plain argv)', () => { diff --git a/packages/fs/tool-fs-search/tsconfig.json b/packages/fs/tool-fs-search/tsconfig.json index ad0c703117..76644e4523 100644 --- a/packages/fs/tool-fs-search/tsconfig.json +++ b/packages/fs/tool-fs-search/tsconfig.json @@ -36,6 +36,9 @@ { "path": "../../spill/spill" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index e24b5f2f07..5721ee2d46 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/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/subagent-acp/README.md -README.md: efcc77c442714a83631d009efb712fa7b8f5dfa0 -README.zh.md: 216d66ed5d259c1bf2ea2383356df97be3e5ced1 +README.md: ead942668f19c44f552d3feb556c39c13b656dea +README.zh.md: e1204ff62f23c8586852111687574a60b0bb7302 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index efcc77c442..ead942668f 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -30,8 +30,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. | | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | -| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. | -| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. | +| `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 216d66ed5d..e1204ff62f 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 @@ -30,8 +30,8 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 | | `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | -| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间。 | -| `disposeGraceMs` | `3000` | 终止后的退出确认宽限时间;POSIX 在 SIGTERM 后、SIGKILL 前也会等待同样时长。 | +| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | +| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包(package)没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index b06a5e50ea..a8509abfac 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 8616f7ae94..fa7c031760 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -17,6 +17,7 @@ import type { SubagentProvider, SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' @@ -54,10 +55,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent escalates to a signal. + * before the parent escalates to a signal. Must not exceed + * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Termination confirmation window (ms), including forced exit on every platform. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -72,10 +74,10 @@ export const Config: z = z.object({ disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) -/** A dispose grace must be a positive finite number (it bounds the teardown wait). */ +/** A dispose grace must fit the single Node timer that owns its teardown tier. */ function assertPositiveFinite(name: string, value: number): void { - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`subagent-acp: ${name} must be a positive finite number`) + if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`subagent-acp: ${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index fba0403739..f3e155e649 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -62,9 +62,9 @@ export interface AcpRunSpec { */ disposeEofGraceMs: number /** - * Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after - * `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin - * fills this from its `disposeGraceMs` config. + * Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX + * waits this long after `SIGTERM` before `SIGKILL`, while Windows + * force-terminates directly. The plugin fills it from `disposeGraceMs`. */ disposeGraceMs: number /** @@ -105,14 +105,12 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { +export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise { // A spawn failure has no process to tear down; observe the rejection so // disposal in a finally block cannot surface it as unhandled. if (child.pid <= 0) { @@ -121,13 +119,10 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe } child.stdin?.end() if (await treeExitsWithin(child, eofGraceMs)) return - // terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace - // (this plugin passes disposeGraceMs there), so the bound covers both the - // escalation window and an equal confirmation window after the SIGKILL. + // terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is + // the process owner's exit proof, not a second derived grace that can overflow. child.terminate() - if (!(await treeExitsWithin(child, graceMs * 2))) { - throw new Error('ACP child process tree did not exit within its dispose windows') - } + await child.waitForExit() } /** @@ -235,7 +230,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs)) + const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs)) // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index f2cbeda27b..6c6c238e74 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -7,6 +7,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -147,7 +148,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { const child = bash('read -r line; exit 0') - await disposeAcpChild(child, 5_000, 200) + await disposeAcpChild(child, 5_000) const outcome = await child.done expect(outcome.exitCode).toBe(0) expect(outcome.signal).toBeNull() @@ -155,7 +156,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => { const child = bash('sleep 60') - await disposeAcpChild(child, 100, 5_000) + await disposeAcpChild(child, 100) const outcome = await child.done expect(outcome.signal).toBe('SIGTERM') }) @@ -166,30 +167,11 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', while (!child.collected.stdout!.readFrom(0).text.includes('armed')) { await new Promise(resolve => setTimeout(resolve, 10)) } - await disposeAcpChild(child, 50, 2_000) + await disposeAcpChild(child, 50) const outcome = await child.done expect(outcome.signal).toBe('SIGKILL') }) - it('throws when the tree survives even the escalation window', async () => { - // A handle whose tree never exits (waitForExit only ever aborts): the - // ladder must fail loud instead of resolving over survivors. Built as a - // stub because the ladder composes only public verbs. - const never: Parameters[0] = { - pid: 1, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: {}, - done: new Promise(() => {}), - terminate: () => {}, - waitForExit: (signal?: AbortSignal) => new Promise((resolve) => { - signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) - }), - } - await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) - }) - it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ argv: ['bash', '-c', 'true'], @@ -197,7 +179,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, graceMs: 200, }) - await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined() + await expect(disposeAcpChild(child, 1_000)).resolves.toBeUndefined() await expect(child.done).rejects.toThrow() }) }) @@ -721,13 +703,20 @@ describe('dsh-subagent-acp', () => { } }) - it('rejects a non-positive dispose grace at load', async () => { - for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { + it('rejects a dispose grace outside the Node timer range at load', async () => { + for (const bad of [ + { disposeEofGraceMs: 0 }, + { disposeGraceMs: -1 }, + { disposeEofGraceMs: Number.NaN }, + { disposeGraceMs: Number.POSITIVE_INFINITY }, + { disposeEofGraceMs: MAX_TIMER_DELAY_MS + 1 }, + { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }, + ]) { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) - .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/) + .rejects.toThrow(new RegExp(`subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)) await ctx.fiber.dispose() } }) diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 2d60858d4a..c7966ddc6f 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/loader-smoke" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index cc1d016225..b6c9853227 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -40,6 +40,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a817239885..ee37a62d76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3143,6 +3143,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -5082,6 +5085,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -5092,6 +5098,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 8e2562d8b9..5f68d91a8c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'], - note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex'], + note: 'The bash executors, the LSP host, and the out-of-process ACP and Codex subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', From e2b73d278ceda2b6dedb590002d5deca390ef042 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 05:34:32 +0800 Subject: [PATCH 036/179] Regenerate module graph --- docs/module-graph.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 39693da6a7..6bd69c01c7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -720,6 +720,7 @@ flowchart TD pkg_tool_fs_search --> pkg_spill pkg_tool_fs_search --> pkg_subprocess pkg_tool_fs_search --> pkg_system_prompt + pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_invariants @@ -912,6 +913,7 @@ flowchart TD pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess + pkg_subagent_acp --> pkg_timeout pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -1211,7 +1213,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1242,7 +1244,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | From 13d7318dace1316e82b91a397c338aaff669a5b5 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 06:12:42 +0800 Subject: [PATCH 037/179] Fix provider diagnostics and ownership docs --- packages/subagent/README.i18n.yaml | 4 ++-- packages/subagent/README.md | 2 +- packages/subagent/README.zh.md | 2 +- packages/subagent/subagent-codex/src/index.ts | 8 ++++++- .../tests/subagent-codex.spec.ts | 21 +++++++++++++++++++ packages/subprocess/README.i18n.yaml | 4 ++-- packages/subprocess/README.md | 6 +++--- packages/subprocess/README.zh.md | 6 +++--- 8 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 875a9c93a7..41b9c1ae5f 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/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/README.md -README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5 -README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd +README.md: 4008bbb2a69c058fc49b33fd3e7b2e2b426d1197 +README.zh.md: 1ef95a5bdfaa68205ddf428b1f3ec177e4dc39f7 diff --git a/packages/subagent/README.md b/packages/subagent/README.md index abe1432d3c..4008bbb2a6 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -17,6 +17,6 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) | -The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other). The out-of-process `subagent-acp` / `subagent-codex` backends spawn through the [`subprocess/`](../subprocess/README.md) seam, which owns credential scrubbing, termination escalation, and whole-tree exit observation; `subagent-dsh-sdk` instead delegates process creation and teardown to the TypeScript SDK client that owns its transport, while reusing the seam's credential scrub. Tests replace only external or nondeterministic product boundaries with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 3df2b6c62d..1ef95a5bdf 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -17,6 +17,6 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) | | `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) | -接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖)。进程外 `subagent-acp` / `subagent-codex` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程,该 seam 拥有凭据清除、终止升级和整棵进程树的退出观测;`subagent-dsh-sdk` 则将进程创建和拆卸委托给拥有自身传输的 TypeScript SDK 客户端,同时复用该 seam 的凭据清除机制。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 09ece5e22e..23077e3b54 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -55,11 +55,17 @@ class CodexProvider implements SubagentProvider { ) {} start(request: ResolvedSubagentStartRequest) { + const parentCwd = request.parent.session.header.cwd + if (parentCwd === undefined) { + throw new Error( + 'subagent-codex: no working directory for the child — delegate from a parent session that has one', + ) + } const spec: CodexRunSpec = { cwd: resolveChildCwd( 'subagent-codex', undefined, - request.parent.session.header.cwd, + parentCwd, ), env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 52d2f558c3..00e3a7e8d6 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -300,6 +300,27 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('requires a parent session cwd without suggesting unsupported config', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + await ctx.plugin(codex, {}) + + await expect(ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'task' }], + parent: { + id: 'parent-without-cwd', + session: { header: {} }, + } as unknown as Agent, + signal: new AbortController().signal, + })).rejects.toThrow( + 'subagent-codex: no working directory for the child — delegate from a parent session that has one', + ) + expect(spawn).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + it('keeps the namespace export shape and package-owned empty invariant', async () => { expect('default' in codex).toBe(false) expect(codex.name).toBe('subagent-codex') diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 5f310fc9b3..7c9640d3c9 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/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/subprocess/README.md -README.md: 64e4740c7ac2706e45bb3517891504bf31a6109b -README.zh.md: 615b492fa7da5b12a7d0cecfe98fc4f1eb94b504 +README.md: ca220a3b715a130c89f8667542b9cd94baa90b3e +README.zh.md: 1d3d58a1d593bce7bb99c38cdc78b5088971f108 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 64e4740c7a..ca220a3b71 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and whole-tree exit observation. Command defaulting, shell semantics, deadlines, cooperative shutdown sequencing, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). | Package | ctx key | Role | |---|---|---| -| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, and whole-tree exit observation | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 615b492fa7..1d3d58a1d5 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -这里集中提供受管子进程树的 spawn 能力:完整指定的 spawn spec,采用 Node 风格、按流划分的 stdio 处置方式(disposition),包括原始管道、inherit、附带 spill 文件的有界尾部保留收集;harness 中所有 spawn 调用方共用的凭据清除机制;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及协作式 dispose(资源释放)阶梯。命令默认值补全、shell 语义、时限、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[subprocess seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +这里集中提供受管子进程树的 spawn 能力:完整指定的 spawn spec,采用 Node 风格、按流划分的 stdio 处置方式(disposition),包括原始管道、inherit、附带 spill 文件的有界尾部保留收集;harness 中所有 spawn 调用方共用的凭据清除机制;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及整棵进程树的退出观测。命令默认值补全、shell 语义、时限、协作式关闭顺序、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[subprocess seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 | 包(package) | ctx 键 | 角色 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | -| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | +| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送,以及整棵进程树的退出观测 | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 From 2e00a93edaa0b3bf846ea0556d30e3f551492c6d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 07:03:04 +0800 Subject: [PATCH 038/179] fix(subagent-codex): support Windows command shims --- ...code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 19 ++++++++++++++++++- .../tests/subagent-codex.spec.ts | 16 +++++++++++++++- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index d63b100325..790f24a0d4 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: fc5e8b6dc5a109fe325530646348ccffaf5458ac -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f68c487ee8494908e5e7748b5881a888af688c82 +2026-08-04-claude-code-and-codex-subagent-backends.md: f0b642d488ce585879deab91bd152b2ca4e68ea6 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f3d0010163b402e79c78188d4686963abfa21569 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index fc5e8b6dc5..f0b642d488 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -39,7 +39,7 @@ fixed tool → shared subagent service → product provider → official product Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed wire data, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index f68c487ee8..f3d0010163 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -39,7 +39,7 @@ fixed tool → shared subagent service → product provider → official product 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 9f52e18f12..ecf71fb2ba 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -24,6 +24,23 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** + * Resolve the fixed app-server command for a platform. + * + * Windows npm and pnpm installs expose `codex.cmd`, which requires `cmd.exe`; + * the argv is constant so no task or configuration text enters the + * shell boundary. + * @param platform - host platform used to select the executable boundary. + * @returns argv for the fixed Codex app-server command. + */ +export function codexAppServerArgv( + platform: NodeJS.Platform = process.platform, +): string[] { + return platform === 'win32' + ? ['cmd.exe', '/d', '/s', '/c', 'codex', 'app-server', '--stdio'] + : ['codex', 'app-server', '--stdio'] +} + /** Fully resolved inputs for one Codex app-server run. */ export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ @@ -106,7 +123,7 @@ export async function startCodexRun( } const child = spec.spawn({ - argv: ['codex', 'app-server', '--stdio'], + argv: codexAppServerArgv(), cwd: spec.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: spec.disposeGraceMs, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 00e3a7e8d6..37c8649f8b 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -15,6 +15,7 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { + codexAppServerArgv, DEFAULT_DISPOSE_GRACE_MS, disposeCodexChild, startCodexRun, @@ -259,6 +260,19 @@ function turnCompleted( } describe('task admission and package contracts', () => { + it('resolves the fixed app-server command through the Windows npm shim boundary', () => { + expect(codexAppServerArgv('win32')).toEqual([ + 'cmd.exe', + '/d', + '/s', + '/c', + 'codex', + 'app-server', + '--stdio', + ]) + expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio']) + }) + it('accepts one or more text blocks and rejects empty or non-text tasks', () => { expect(textTask([ { type: 'text', text: 'one' }, @@ -868,7 +882,7 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) const run = await starting expect(spawn).toHaveBeenCalledWith({ - argv: ['codex', 'app-server', '--stdio'], + argv: codexAppServerArgv(), cwd: process.cwd(), stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: DEFAULT_DISPOSE_GRACE_MS, From 6d8095825b9bb1ed7703dd011ef0eeb98b7170bc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 07:32:27 +0800 Subject: [PATCH 039/179] fix(subagent-claude-code): tighten provider evidence --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent-claude-code/src/index.ts | 8 +++- .../tests/messages-fixture.ts | 2 +- .../tests/real-product.spec.ts | 47 ++++++++++++++++++- .../tests/subagent-claude-code.spec.ts | 11 +++++ scripts/gen-third-party-notices.spec.ts | 4 +- scripts/gen-third-party-notices.ts | 2 +- 13 files changed, 78 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 80508d16b4..1884c12c93 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 35fe7b7aaf02d9d55012e3285b3f5a58bc76cde8 -2026-06-21-subagent-capability-seam.zh.md: 221335859cec104a55136201e4923d783d616e86 +2026-06-21-subagent-capability-seam.md: 752e639b09ea2ac0ba19841ddfe38b15b44d22e9 +2026-06-21-subagent-capability-seam.zh.md: 6009aeda6773357a4217f8956fb510c2116bbe3f diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 35fe7b7aaf..752e639b09 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -54,7 +54,7 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag ### Child isolation and the parent log -Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. The parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic. +Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log. ### Synchronous collect (first cut) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 221335859c..6009aeda67 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -54,7 +54,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 子 agent 隔离与父日志 -每个 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)——子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,绝不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件在物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 真正与传输方式无关。 +每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用均留在父日志之外。 ### 同步收集(首版) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d6910daedc..96ce7ecd37 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -3,7 +3,7 @@ # Third-Party Notices -DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. +DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 43c08d5394..6bc638bdc4 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent-claude-code/README.md -README.md: 5bfc8ff3ba539b6caf891577cee5820d983106cd -README.zh.md: 6db9148228ba30c0f84beb14caa7a84aa0dcd9f5 +README.md: e62f60fceea16749296a91377785b81d94d751ca +README.zh.md: e171524157b2b1696df31753816210d41637a911 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 5bfc8ff3ba..e62f60fcee 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. +Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 6db9148228..e171524157 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。 +生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 1ba7266e0f..e4d6fbac5f 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -60,11 +60,17 @@ class ClaudeCodeProvider implements SubagentProvider { ) {} start(request: ResolvedSubagentStartRequest) { + const parentCwd = request.parent.session.header.cwd + if (parentCwd === undefined) { + throw new Error( + 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', + ) + } const spec: ClaudeCodeRunSpec = { cwd: resolveChildCwd( 'subagent-claude-code', undefined, - request.parent.session.header.cwd, + parentCwd, ), env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts index 6f84fd2395..d8a04cf953 100644 --- a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -99,7 +99,7 @@ export async function startMessagesFixture( request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) request.on('end', () => { const path = request.url ?? '' - if (!path.startsWith('/v1/messages')) { + if (path !== '/v1/messages' && !path.startsWith('/v1/messages?')) { response.writeHead(404, { 'content-type': 'application/json' }) response.end(JSON.stringify({ type: 'error', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index a0d1c1a02d..f76b4038f6 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -10,6 +10,11 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' +import type { + Query, + SDKMessage, + SDKSystemMessage, +} from '@anthropic-ai/claude-agent-sdk' import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -23,6 +28,39 @@ import { type MessagesFixture, } from './messages-fixture.ts' +const observedSdkMessages = vi.hoisted((): SDKMessage[] => []) + +vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@anthropic-ai/claude-agent-sdk') + >() + return { + ...actual, + query(options: Parameters[0]): Query { + const query = actual.query(options) + // Observe the real SDK stream without replacing its protocol or CLI. + return new Proxy(query, { + get(target, property) { + if (property === Symbol.asyncIterator) { + return async function* (): AsyncGenerator { + for await (const message of target) { + observedSdkMessages.push(message) + yield message + } + } + } + const value: unknown = Reflect.get(target, property, target) + if (typeof value === 'function') { + const method = value as (...args: unknown[]) => unknown + return method.bind(target) + } + return value + }, + }) + }, + } +}) + const execFileAsync = promisify(execFile) const sdkRoot = dirname(fileURLToPath( import.meta.resolve('@anthropic-ai/claude-agent-sdk'), @@ -54,6 +92,7 @@ afterEach(async () => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } + observedSdkMessages.length = 0 }) interface RealHarness { @@ -168,10 +207,16 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { }) await run.dispose() + const initMessage = observedSdkMessages.find( + (message): message is SDKSystemMessage => + message.type === 'system' && message.subtype === 'init', + ) + expect(initMessage?.claude_code_version).toBe('2.1.220') + expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! expect(recorded.method).toBe('POST') - expect(recorded.path).toMatch(/^\/v1\/messages(?:\\?|$)/) + expect(recorded.path).toMatch(/^\/v1\/messages(?:\?.*)?$/) expect(recorded.headers['x-api-key']).toBe(fakeKey) expect(recorded.body.model).toBe(settingsModel) expect(Array.isArray(recorded.body.messages)).toBe(true) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 7a2942b084..8c4ac1708d 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -341,6 +341,17 @@ describe('task admission and package contracts', () => { disposeGraceMs: 29, }) + await expect(ctx.subagents.start('claude-code', { + ...request(), + parent: { + id: 'parent-without-cwd', + session: { header: {} }, + } as unknown as Agent, + })).rejects.toThrow( + 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', + ) + expect(queryMock).not.toHaveBeenCalled() + const run = await ctx.subagents.start('claude-code', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index e6e199c21a..d0c427c8f6 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -25,7 +25,9 @@ describe('THIRD_PARTY_NOTICES.md', () => { // Pre-commit regenerates the file whenever a manifest is staged, so reaching // this assertion means the notices were committed without that hook. it('matches what the generator produces from the current manifests', () => { - expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render()) + const generated = render() + expect(generated).toContain('It depends on the third-party software listed below.') + expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) }) }) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 3e2e0917f2..20f3bd04f0 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -694,7 +694,7 @@ export function render(): string { # Third-Party Notices -DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. +DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. From 9e91e206d4ad1662a843956cf73892aa4f3bf94a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 07:36:54 +0800 Subject: [PATCH 040/179] refactor(subagent-codex): centralize cancellation settlement --- packages/subagent/subagent-codex/src/run.ts | 2 +- packages/subagent/subagent-codex/src/wire.ts | 4 -- .../tests/subagent-codex.spec.ts | 44 +++++-------------- 3 files changed, 13 insertions(+), 37 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index ecf71fb2ba..c3ebf4ba19 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -179,7 +179,7 @@ export async function startCodexRun( const collectOutput = (): ContentBlock[] => wire.collectOutput() const result: Promise = settleRunResult({ attempt: () => Promise.race([ - wire.runTurn(texts, runAbort.signal, () => runAbort.signal.aborted), + wire.runTurn(texts, runAbort.signal), processFailure, ]), collectOutput, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index ca24fdcadf..51be212841 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -168,13 +168,11 @@ export class CodexAppServerWire { * terminal notification. * @param texts - already validated task text blocks. * @param signal - local cancellation for the published run. - * @param cancelled - whether local cancellation has already won. * @returns the shared subagent result. */ async runTurn( texts: readonly string[], signal: AbortSignal, - cancelled: () => boolean, ): Promise { const completion = Promise.withResolvers() this.turnCompleted = completion @@ -187,8 +185,6 @@ export class CodexAppServerWire { this.commitTurnId(string(turn.id, 'turn/start turn id')) const completed = await this.guarded(completion.promise, signal) - if (cancelled()) return { output: this.collectOutput(), stopReason: 'aborted' } - const terminal = object(completed.turn, 'turn/completed turn') const status = terminal.status if (isContextWindowExceeded(terminal)) { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 37c8649f8b..de89aa4854 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -393,7 +393,6 @@ describe('CodexAppServerWire', () => { const result = wire.runTurn( ['first', 'second'], new AbortController().signal, - () => false, ) const turnStart = await child.peer.nextMethod('turn/start') expect(turnStart.params).toEqual({ @@ -437,7 +436,7 @@ describe('CodexAppServerWire', () => { it('uses the last nullable-phase answer when no explicit final exists', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send( @@ -454,7 +453,7 @@ describe('CodexAppServerWire', () => { it('maps only an explicit context-window failure to max-tokens', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send( @@ -494,7 +493,7 @@ describe('CodexAppServerWire', () => { } { const { child, wire } = await initializeWire() - const pending = wire.runTurn(['task'], new AbortController().signal, () => false) + const pending = wire.runTurn(['task'], new AbortController().signal) const frame = await child.peer.nextMethod('turn/start') child.peer.respond(frame, { turn: { id: '' } }) await expect(pending).rejects.toThrow('turn/start turn id') @@ -542,7 +541,7 @@ describe('CodexAppServerWire', () => { ] for (const scenario of scenarios) { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send(...scenario.frames) @@ -553,7 +552,7 @@ describe('CodexAppServerWire', () => { it('fails closed when terminal notification params are not an object', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send({ method: 'turn/completed', params: null }) @@ -563,7 +562,7 @@ describe('CodexAppServerWire', () => { it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.send( { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, @@ -575,28 +574,9 @@ describe('CodexAppServerWire', () => { wire.close() }) - it('gives local cancellation precedence over a remote completed turn', async () => { - const { child, wire } = await initializeWire() - let cancelled = false - const result = wire.runTurn( - ['task'], - new AbortController().signal, - () => cancelled, - ) - const turnStart = await child.peer.nextMethod('turn/start') - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - cancelled = true - child.peer.send(agentMessage('late', 'final_answer'), turnCompleted('completed')) - await expect(result).resolves.toEqual({ - output: [{ type: 'text', text: 'late' }], - stopReason: 'aborted', - }) - wire.close() - }) - it('answers all five unattended request classes without granting authority', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.send({ @@ -699,7 +679,7 @@ describe('CodexAppServerWire', () => { }, ]) { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() @@ -713,7 +693,7 @@ describe('CodexAppServerWire', () => { it('rejects conflicting early turn identities before accepting output', async () => { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.send({ method: 'turn/started', @@ -738,7 +718,7 @@ describe('CodexAppServerWire', () => { } { const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) await child.peer.nextMethod('turn/start') child.peer.send( { @@ -755,7 +735,7 @@ describe('CodexAppServerWire', () => { it('interrupts only an active open turn and contains remote interrupt failure', async () => { const { child, wire } = await initializeWire() wire.interrupt() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() @@ -790,7 +770,7 @@ describe('CodexAppServerWire', () => { ) await nextTask() - const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() From 340c9ba57647b6968805095d914b475f2afe472b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 12:15:03 +0800 Subject: [PATCH 041/179] feat(web): split context injection into producer-declared forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injected context reached the transcript as one anonymous shape whose expanded body was the whole message serialized as inline JSON, so the escaping collapsed the only readable part — the model-facing prose — into a single line. `MessageSource` gains an optional producer-declared `form`, a small semantic vocabulary of information shapes independent of `kind`: kind says who produced the context, form says what shape it is, so several producers may share one presentation. Two values ship. `instructions` (workspace-context) lists the reconciled files above their text and keeps the `` framing verbatim, because the framing is part of what the model read. `catalog` moves dsh-tool-skill off the shared plugin kind onto a `skill-catalog` source carrying the published name/description entries, and the body lists those instead of re-parsing `` out of the prose. Catalog identity moves with it: the republish digest now covers the durable entries, deleting the text-slicing that recovered them from a logged message. Everything else renders the opaque body — the model-facing text with its real line breaks, then the remaining provenance as fields. That is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here, which is why the classification lives in the durable source rather than a client-side table keyed by producer. --- ...b-context-source-and-steer-marks.i18n.yaml | 4 +- ...8-04-web-context-source-and-steer-marks.md | 2 +- ...4-web-context-source-and-steer-marks.zh.md | 2 +- ...26-08-05-context-form-vocabulary.i18n.yaml | 6 + .../2026-08-05-context-form-vocabulary.md | 58 +++++ .../2026-08-05-context-form-vocabulary.zh.md | 58 +++++ apps/web/tests/seeded-history.e2e.ts | 9 +- docs/config-catalog.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/core.zh.md | 2 +- .../code-mode-workspace-context/session.jsonl | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../tests/snapshots/skill-load/session.jsonl | 2 +- .../snapshots/workspace-context/session.jsonl | 6 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/src/client/index.ts | 4 +- .../client/session-history/history-fold.ts | 3 +- .../src/client/sessions/context-provenance.ts | 25 ++ .../src/client/sessions/conversation.ts | 4 +- .../src/client/sessions/transcript-adapter.ts | 3 +- .../runtime/tests/context-provenance.spec.ts | Bin 3196 -> 4148 bytes .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ContextBody.module.css | 96 ++++++++ .../src/client/chat/ContextBody.tsx | 227 ++++++++++++++++++ .../chat/ContextInjectionRow.module.css | 3 +- .../src/client/chat/ContextInjectionRow.tsx | 67 ++---- .../src/client/chat/MessageItem.tsx | 1 + .../ui-conversation/src/client/locales.ts | 6 + .../tests/chat-branch-tails.spec.tsx | 85 ++++++- .../ui-conversation/tests/chat-view.spec.tsx | 1 + .../tests/context-branches.spec.ts | 1 + .../context/workspace-context/src/index.ts | 1 + .../context/workspace-context/src/state.ts | 4 +- .../tests/workspace-context.spec.ts | 9 +- .../cordis/tool-cordis/src/api-catalog.ts | 10 +- .../agent-spine-demo/tests/agent-core.spec.ts | 14 +- packages/llm/llm/src/message.ts | 29 ++- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 105 ++++---- .../skill/tool-skill/tests/tool-skill.spec.ts | 60 +++-- 51 files changed, 779 insertions(+), 174 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md create mode 100644 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md create mode 100644 packages/client/ui-conversation/src/client/chat/ContextBody.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/ContextBody.tsx diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index c7695cfd3f..ed4205dbe2 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 5d158039b742af58a91b0e9d194b98e89485d8d7 -2026-08-04-web-context-source-and-steer-marks.zh.md: 46f5285033671407c1d26794cfd4ba74c06a8ad9 +2026-08-04-web-context-source-and-steer-marks.md: dca08d003df810932a2b5565e42d167781d593f7 +2026-08-04-web-context-source-and-steer-marks.zh.md: b0dc4c880778df86965e2732a69aaddd0c85d80f diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 5d158039b7..dca08d003d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -14,7 +14,7 @@ The distinctions are already durable. `user/message.source` is the merge-extensi The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. -`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the expanded body, its 141px scrollport, and the truncation policy are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). +`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). **The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index 46f5285033..b0dc4c8807 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -14,7 +14,7 @@ Status: implemented transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 -`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;展开内容区、141px 滚动视口与截断策略沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。 +`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 **名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml new file mode 100644 index 0000000000..aeba2cd585 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +2026-08-05-context-form-vocabulary.md: c2369a21a474e677eb324b6f8d2457091ee44e30 +2026-08-05-context-form-vocabulary.zh.md: 3c16279a4e36742eb7c5d60aa1c5ad9a2ae07cd6 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md new file mode 100644 index 0000000000..c2369a21a4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -0,0 +1,58 @@ +# Agent Note: Producer-declared context forms + +Status: implemented + +English | [中文](2026-08-05-context-form-vocabulary.zh.md) + +## Problem + +Every logged non-user `user/message` rendered through one body: the whole message serialized as inline JSON. A reader opening a row met `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`, where the escaping had collapsed the only thing worth reading — the model-facing prose — into a single line, and the provenance sat inside the same blob. + +Naming the producer in the header (the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md)) fixed *who added this*. It could not fix *what kind of thing was added*, because nothing in the log said so. Injected context is not one shape: a reconciled `AGENTS.md`, a catalog of available skills, a runtime policy snapshot, and a subagent's report are as different from each other as a terminal card is from a diff card, yet all four presented as the same wall of escaped JSON. + +The tool surface already solved this shape. `ToolCallView` has three cards, not one per tool, and a tool declares which card its call is. Context had no equivalent: no vocabulary of shapes, and no way for a producer to say which one it emits. + +## Decision + +`MessageSource` gains an optional producer-declared `form: ContextForm` — a small tagged vocabulary of information *shapes*, independent of `kind`: + +- `kind` answers **who produced this** and remains pure provenance. +- `form` answers **what shape of information it is**. Several producers may share one form, and one producer may emit more than one over a session. + +The vocabulary is semantic, never visual. A value states that the content is a file's instructions or a catalog of available items; colors, icons, ordering, and collapse defaults are the consumer's business and must not enter the union. It grows one value at a time, as producers gain the structured fields their form needs. This release declares two: + +**`instructions`** — instructions read out of workspace files. `workspace-context` declares it on both the startup baseline and later deltas; its existing `changes[]` already carried the paths, actions, and digests the presentation needs, so no field was added. The body lists the reconciled files above the text, and keeps the `` framing verbatim: the framing is part of what the model read, so hiding it would misreport the request. + +**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement. The body lists those entries instead of re-parsing the `` block out of the prose. + +That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. + +Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining provenance as fields. Opaque is the documented default, not a leftover bin. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer. + +## Why not a presenter registry + +The tool seam pairs its vocabulary with `presentCall(args)`, a host-side pure function each tool implements. Context deliberately has no equivalent, because the input differs in ownership: a tool's `args` are generated by the **model** against a model-facing schema, so a translation step is unavoidable; a context `source` is constructed by the **producing plugin** itself, under no external constraint, and can simply record the facts a presentation needs. Adding a registry would have bought a translation nobody needs, at the cost of a host computation point, a wire field per context message, and a browser bundle for every producing package (the client purity gate forbids host packages from contributing components). + +## Alternatives considered + +**Map source kinds to renderers in the client.** Cheapest to write and requires no format change, but it puts producer knowledge back in the client: every new kind then needs a client release to render as anything but opaque, and a foreign log cannot be classified at all. It also reintroduces exactly the coupling the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md) removed for labels. + +**Reuse `kind` as the form.** One discriminant is simpler, and `workspace-instructions` is already 1:1 with its form. It breaks on the shared shapes: three producers emit runtime snapshots today, and folding them into one kind would erase their provenance. Two axes keep provenance exact while letting presentations be shared. + +**Let the client parse the model-facing prose.** The entries and file sections are visibly structured in the text. Parsing them couples the presentation to prompt wording, so every reword silently breaks a card — the same reason catalog identity moved off the text. + +**Render instructions as Markdown.** The body is a Markdown file and would read better rendered. The text also carries `` framing, which the Markdown renderer drops as raw HTML, so a Markdown body would silently hide part of what the model read. Deferred until the producer records per-file content structurally. + +## Testing + +- `packages/client/runtime` pins the form projection, including the unknown, empty, wrongly-typed, and absent values that must degrade to opaque. +- `packages/client/ui-conversation` pins each body: the opaque body's preserved line breaks and provenance fields, the instructions body's file list and verbatim framing, the catalog body's entry list, and a catalog with unusable entries falling back to opaque. +- `packages/skill/tool-skill` pins the new source on first publication and replacement, and republish behavior driven by the durable entries. +- The keyless assembled-Web seeded-history scenario expands a real `instructions` context in Chromium and asserts its file list, verbatim framing, and the unchanged disclosure geometry. `catalog` has no assembled coverage: the hermetic scaffold publishes no skills, so no catalog reaches a browser scenario. + +## Consequences + +- A reader can tell what was added without expanding, and reading it no longer means reading escaped JSON. +- The durable `MessageSource` now carries a semantic classification beside provenance. The boundary is load-bearing: facts and shape only, never presentation. A producer that wants a better card records better facts. +- Catalog identity no longer depends on the model-facing prose, deleting the text-slicing path that could mistake a reworded catalog for a changed one. +- Five forms from the surveyed taxonomy (`snapshot`, `relay`, `recall`, `notice`, and the unclassified remainder) are not declared yet; their producers render opaque until they record the fields their form needs. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md new file mode 100644 index 0000000000..3c16279a4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -0,0 +1,58 @@ +# Agent Note:由生产方声明的上下文形态 + +Status: implemented + +[English](2026-08-05-context-form-vocabulary.md) | 中文 + +## Problem + +每一条已记录的非用户 `user/message` 都通过同一个内容区渲染:把整条消息序列化成内联 JSON。读者展开一行,看到的是 `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`——转义把唯一值得读的东西(面向模型的散文)压成了一行,而来源信息又和它挤在同一坨里。 + +在标题栏写出生产者([来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.md))解决了「这是谁加的」。它解决不了「加进来的是什么东西」,因为日志里根本没有这句话。注入上下文不是一种形状:对账后的 `AGENTS.md`、可用 skill 的目录、运行时策略快照、子 agent 的汇报,彼此之间的差别不亚于终端卡片与 diff 卡片,然而这四者呈现出来是同一堵转义 JSON 的墙。 + +工具面早就解决过同一个形状问题。`ToolCallView` 只有三种卡片,而不是每个工具一种,由工具自己声明本次调用属于哪一种。上下文没有对应物:既没有形状词汇表,生产方也无从声明自己发出的是哪一种。 + +## Decision + +`MessageSource` 新增一个可选、由生产方声明的 `form: ContextForm`——一份关于信息**形状**的小型 tagged 词汇表,与 `kind` 相互独立: + +- `kind` 回答**由谁产生**,保持纯粹的溯源语义。 +- `form` 回答**这是何种形态的信息**。多个生产方可以共用一种形态,一个生产方在一次会话中也可以发出多种。 + +该词汇表是语义的,绝不涉及视觉。取值只陈述「内容是某个文件的指令」或「是一份可用项目录」;颜色、图标、排序、默认折叠状态归消费方管,不得进入这个联合类型。它随生产方补齐各自形态所需的结构化字段而逐个增长。本次声明两个: + +**`instructions`**——从工作区文件中读出的指令。`workspace-context` 在启动基线与后续增量上都声明它;其既有的 `changes[]` 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 `` 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。 + +**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`。内容区直接列出这些条目,不再从散文里反解 `` 块。 + +这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。 + +其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源信息列成字段。opaque 是有文档的默认,不是兜底垃圾桶。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 + +## 为什么不做 presenter 注册表 + +工具接缝把它的词汇表与 `presentCall(args)` 配对,那是每个工具在 host 侧实现的纯函数。上下文刻意不设对应物,因为输入的归属不同:工具的 `args` 由**模型**按面向模型的 schema 生成,翻译步骤无法回避;而上下文的 `source` 由**生产方插件**自己构造,不受任何外部约束,完全可以直接记录呈现所需的事实。加一层注册表买到的是一次没人需要的翻译,代价却是一个 host 计算点、每条上下文消息一个 wire 字段、以及每个生产方包都要出浏览器 bundle(客户端纯度门禁禁止 host 包贡献组件)。 + +## Alternatives considered + +**在客户端把来源 kind 映射到渲染器。** 写起来最省,也不用改格式,但它把生产方知识放回了客户端:此后每新增一个 kind 都要客户端发版才能渲染成 opaque 以外的东西,而外部日志根本无法分类。它还会重新引入[来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.md)刚为名称去掉的那种耦合。 + +**复用 `kind` 充当形态。** 单一判别式更简单,`workspace-instructions` 本来也与它的形态一一对应。但它在共享形状上就崩了:今天有三个生产方发出运行时快照,把它们并成一个 kind 会抹掉各自的溯源。两根轴既保住溯源的精确,又让呈现可以共享。 + +**让客户端解析面向模型的散文。** 条目与文件分节在文本里确实有可见结构。解析它们会把呈现耦合到 prompt 措辞上,于是每改一次文案就静默碎掉一张卡——这也正是目录身份从文本上迁走的原因。 + +**把 instructions 渲染成 Markdown。** 正文本来就是 Markdown 文件,渲染出来更好读。但文本同时携带 `` 包装,Markdown 渲染器会把它当原始 HTML 丢弃,于是 Markdown 内容区会悄悄隐藏模型读到的一部分。推迟到生产方按文件结构化记录内容之后再做。 + +## Testing + +- `packages/client/runtime` 钉住形态投影,包括必须降级为 opaque 的未知值、空值、类型不符与缺失。 +- `packages/client/ui-conversation` 逐个钉住内容区:opaque 的换行留存与来源字段、instructions 的文件列表与原样包装、catalog 的条目列表,以及条目不可用的 catalog 回落到 opaque。 +- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源,以及由持久条目驱动的重新发布行为。 +- 无密钥的组装 Web seeded-history 场景在 Chromium 中展开一条真实的 `instructions` 上下文,断言其文件列表、原样包装与未改动的展开项几何。`catalog` 没有组装态覆盖:隔离脚手架不发布任何 skill,因此没有目录能进入浏览器场景。 + +## Consequences + +- 读者不展开就能知道加进来的是什么,展开之后读到的也不再是转义 JSON。 +- 持久 `MessageSource` 现在在溯源之外还承载一个语义分类。这条边界是承重的:只放事实与形状,绝不放呈现。想要更好卡片的生产方应当记录更好的事实。 +- 目录身份不再依赖面向模型的散文,删掉了那条可能把「改了措辞」误判为「改了内容」的文本切分路径。 +- 已勘定的分类里还有五种形态(`snapshot`、`relay`、`recall`、`notice`,以及未归类的其余部分)尚未声明;在它们的生产方补齐各自形态所需字段之前,一律渲染 opaque。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 784589d9c7..627e3234a8 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -227,6 +227,7 @@ describe('web e2e: seeded history renders through cold resume', () => { }], source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [{ action: 'set', @@ -268,6 +269,10 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') const body = page.locator('[data-context-injection-body]') await body.waitFor({ timeout: 5_000 }) + // The instructions form names the file it reconciled above the text, and + // the text keeps the framing the model read rather than a cleaned excerpt. + expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded']) + expect(await body.locator('[data-context-text]').innerText()).toContain('') const headerBox = await disclosure.boundingBox() const bodyBox = await body.boundingBox() if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable') @@ -373,7 +378,9 @@ describe('web e2e: seeded history renders through cold resume', () => { await disclosure.click() await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') - const body = page.locator('[data-context-injection-body]') + // The instructions row above stays expanded from the geometry case; the + // opaque body is the one without a declared form. + const body = page.locator('[data-context-injection-body]:not([data-context-form])') const bodyBox = await body.boundingBox() if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable') expect(bodyBox.height).toBeLessThan(141) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5d238761d9..fc8e1568ea 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1916,7 +1916,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:30`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:58`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 415d74e6f6..b45a0ce588 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 1b5704384157688b45ae0900bf2d9924426bbd6b -core.zh.md: 05802039920163ac8185703ab483c5603087ed96 +core.md: 6c12b8af07498590bb8eb5b92c3c8f6bbef313f3 +core.zh.md: f83e8873adfae5ab81c02678605f4014214b96b6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b57043841..6c12b8af07 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -161,7 +161,7 @@ Where a message came from is itself a merge-extensible sum type: */ interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 0580203992..f83e8873ad 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -167,7 +167,7 @@ interface Message { */ interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 4588ebb898..6c6efd8b89 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"a5066d26-ed57-4f98-8672-b34e883e1299"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785122256262,"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":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464674590,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464674590,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487644564,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -20,7 +20,7 @@ {"type":"tool/code-dispatch-start","seq":105,"time":1785487644629,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} {"type":"tool/code-dispatch","seq":106,"time":1785487644632,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} {"type":"tool/result","seq":107,"time":1785487644634,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"51eadd14-c38c-4ca2-ba35-f5e0d76a31f6"}},"sourceEventSeqs":[104],"surfaceOp":"append"} -{"type":"user/message","seq":108,"time":1785487644634,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"6e280d70-19bc-41fd-91f1-2f03885114c6"},"surfaceOp":"append"} +{"type":"user/message","seq":108,"time":1785487644634,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"6e280d70-19bc-41fd-91f1-2f03885114c6"},"surfaceOp":"append"} {"type":"step/end","seq":109,"time":1785487644635,"data":{"turn":1,"step":1}} {"type":"step/start","seq":110,"time":1785487644641,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":111,"time":1785014477475,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 2bbe6dd86b..d85d672bc9 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -13,7 +13,7 @@ {"type":"assistant/chunk","seq":11,"time":1785487622703,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":12,"time":1785487622703,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7c207b09-7f6e-4e53-a5d2-77e0d2bbb474"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} {"type":"tool/call","seq":13,"time":1785487622703,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n } | {\n readonly kind: 'steer';\n };\n export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextForm = 'instructions' | 'catalog';\n export interface ContextFormed {\n readonly form?: ContextForm;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n } | {\n readonly kind: 'steer';\n };\n export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785487622726,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1785487622735,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":17,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 81703a533d..dd89efb794 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"2243ae1a-2d65-4f9c-a972-d360b8cc08aa"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1784903324926,"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":"021a7fcb-3d54-4ed9-8c2c-ca7565599fd8"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464638477,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464638477,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464638477,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464638478,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487588943,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 0a568d3460..85c17f6948 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7cb62d32-ef8e-4d45-9b5e-d2a1fbdbabbd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -15,7 +15,7 @@ {"type":"assistant/message","seq":13,"time":1785487608779,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6908b415-6aca-462d-9d91-0b27a73ba08c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785487608779,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} {"type":"tool/result","seq":15,"time":1785487608790,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1144df09-c5e0-4781-8734-55acf6f2d4d0"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"ed901b04-9258-4f36-a094-c24127021158"},"surfaceOp":"append"} +{"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"ed901b04-9258-4f36-a094-c24127021158"},"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785487608790,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785487608799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -26,7 +26,7 @@ {"type":"assistant/message","seq":24,"time":1785487608800,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"268c94fb-859c-4ed9-aa98-3a1ccfa31a6f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785487608801,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} {"type":"tool/result","seq":26,"time":1785487608810,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"228be9fc-eacf-4a2e-a475-9d4f46b2606d"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[25],"surfaceOp":"append"} -{"type":"user/message","seq":27,"time":1785487608811,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"f955bfc9-0679-478e-84d8-77e266114c44"},"surfaceOp":"append"} +{"type":"user/message","seq":27,"time":1785487608811,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"f955bfc9-0679-478e-84d8-77e266114c44"},"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785487608811,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785487608818,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1785394278036,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index bc159473de..f6f413c2ca 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"subagent/descriptor","seq":3,"time":1785534749329,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785534749330,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785534749330,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":5,"time":1785534749330,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":6,"time":1785534749330,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index a6f1bfc4c7..0e49dfc1f4 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"subagent/descriptor","seq":3,"time":1785534749466,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785534749467,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785534749468,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":5,"time":1785534749468,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":6,"time":1785534749468,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 255a590d97..c09149f29d 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"50d7fdd8-0423-43a2-b8f4-4aef2829c82e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681498,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 19cd2955d2..8fc8cb452c 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785464685153,"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."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"da0842e3-2231-4abf-a85f-a16acfb0b305"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1785464685153,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":6,"time":1785487564325,"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":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 70bb56110b..c9ebfaabba 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 17b3e73b7c2efa4aa71ff24c9e5a45eaf81771de -README.zh.md: 5fc5981ea3a1f896dddb008271c323e759505dad +README.md: 0d9935571bf84a3ce299a2dd37a81894412ab536 +README.zh.md: 6b6c129ee24ee6a82a21e50924b170c4b9725445 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 17b3e73b7c..0d9935571b 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -28,7 +28,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 5fc5981ea3..6b6c129ee2 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -28,7 +28,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 9ccb534f98..06f88a9131 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -53,7 +53,9 @@ export type { export type { ConversationContext, ConversationContextOriginKind, } from './sessions/conversation-context.ts' -export type { ContextProvenanceView, ContextRole } from './sessions/context-provenance.ts' +export type { + ContextProvenanceView, ContextRole, KnownContextForm, +} from './sessions/context-provenance.ts' export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, } from './sessions/request-inspection.ts' diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index af915b1deb..b1392c8389 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -11,7 +11,7 @@ import type { PartialAssistant, RunningToolCall, } from '../sessions/conversation.ts' import { toAssistantBlocks } from '../sessions/conversation.ts' -import { contextProvenance } from '../sessions/context-provenance.ts' +import { contextForm, contextProvenance } from '../sessions/context-provenance.ts' import type { ConversationContext, ConversationContextOriginKind, } from '../sessions/conversation-context.ts' @@ -128,6 +128,7 @@ function materializeNode( kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), } } return { diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 3237c1a22e..95d44a04fd 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -89,3 +89,28 @@ export function contextProvenance(source: unknown): ContextProvenanceView { return { role: 'inject', label: kind } } } + +/** + * Context forms this UI version renders with a dedicated presentation. The + * durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an + * unrecognized or absent value degrades to the opaque presentation rather than + * dropping the row, so a log written by a newer or foreign producer still + * renders. + */ +const KNOWN_FORMS = ['instructions', 'catalog'] as const + +/** One durable context form this UI version knows how to present. */ +export type KnownContextForm = typeof KNOWN_FORMS[number] + +/** + * Read the producer-declared form off one durable message source. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns the form when this UI version presents it, otherwise null (opaque). + */ +export function contextForm(source: unknown): KnownContextForm | null { + const record = asRecord(source) + const form = record === null ? null : readString(record, 'form') + return form !== null && (KNOWN_FORMS as readonly string[]).includes(form) + ? form as KnownContextForm + : null +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index b4c7ebb71b..8e8e69c151 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -12,7 +12,7 @@ import type { InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' -import type { ContextProvenanceView } from './context-provenance.ts' +import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -126,6 +126,8 @@ export interface ContextMessageNode { source: unknown /** Role and producer name projected from `source` ({@link contextProvenance}). */ provenance: ContextProvenanceView + /** Producer-declared information form ({@link contextForm}); null presents as opaque. */ + form: KnownContextForm | null } /** Durable notice that a closed failed step is waiting for a model-request retry. */ diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 854c461755..6bcc2e3a03 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -22,7 +22,7 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' -import { contextProvenance } from './context-provenance.ts' +import { contextForm, contextProvenance } from './context-provenance.ts' /** * The compaction seam's checkpoint plugin, pinned to the seam's own declaration @@ -62,6 +62,7 @@ function materializeNode( kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), } } return { diff --git a/packages/client/runtime/tests/context-provenance.spec.ts b/packages/client/runtime/tests/context-provenance.spec.ts index 94d1a12038340e835db2da08095a63ac2e9bc250..e4d11f5fe373966528b815e3f4e16c0f4d126aac 100644 GIT binary patch delta 682 zcmaKp%`OB%6on%pm|0j1Vu4(-=&qAu_KZ=_l|A z5-%XO-oQ${hJ{-_J$@q5%Rc9xbL+bwQ;%@>KD2G@S^eSlzPeKp9p93=$u8;Of~*UYFg&V_?+^SU0kE_L z2Mj@E78{D-!lc;9`28I?!w#SdoI21TxRiq7_!jD3{^4%Fq7JyH1z>@N^PI_SN}!2E zQCtFL#(@K%=UBDQx@7#e=n>Ymi^o}WxtOn1Yd!K$!_)cmK&?{n+0T@U^X2MzU#rcQ zT%E?YYZ)JXvHa_uEWQ7ECVHOLzcCZr;l7$TD3A43kQ7QJwevPh!!ca+(^KA(&-n>R Ci{4lO delta 10 Scmdm@@JC|8 | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +/** + * Concatenated text of the content blocks, with the non-text blocks kept aside. + * Context is text in practice (every producer injects one text block), but the + * block union is merge-extensible, so an unknown block keeps its own fallback + * rather than vanishing. + */ +function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } { + const texts: string[] = [] + const rest: unknown[] = [] + for (const block of content) { + if (block.type === 'text') texts.push(block.text) + else rest.push(block) + } + return { text: texts.join('\n'), rest } +} + +/** The model-facing text, truncated to the display bound. */ +function boundedText(text: string, t: Translate): string { + return text.length > MAX_CHARS + ? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}` + : text +} + +/** One source field rendered as a value row; nested shapes stay compact JSON. */ +function fieldValue(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +/** + * Provenance fields as a key/value list. `kind` is omitted because the row + * header already names the producer, and `form` because the presentation the + * reader is looking at IS that value. + */ +function SourceFields({ source }: { source: unknown }): ReactNode { + const record = asRecord(source) + if (record === null) return null + const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form') + if (rows.length === 0) return null + return ( +
+ {rows.map(([key, value]) => ( +
+
{key}
+
{fieldValue(value)}
+
+ ))} +
+ ) +} + +/** + * Default presentation: the model-facing text as text, with its real line + * breaks, and the remaining provenance beneath it. This is what every form + * this UI version does not recognize renders as. + * @param props - Durable content, its source, and the locale seat. + * @returns The opaque context body. + */ +export function OpaqueBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const { text, rest } = partitionContent(content) + return ( + <> + {text !== '' &&
{boundedText(text, t)}
} + {rest.map((block, index) => ( + t('json.truncated', { total })} + /> + ))} + + + ) +} + +/** One reconciled instruction file, as the durable source records it. */ +interface InstructionChange { + action: string + path: string + digest?: string +} + +/** Instruction changes read off the source; empty when the shape is unusable. */ +function instructionChanges(source: unknown): InstructionChange[] { + const record = asRecord(source) + const list = record === null ? undefined : record['changes'] + if (!Array.isArray(list)) return [] + const changes: InstructionChange[] = [] + for (const entry of list) { + const change = asRecord(entry) + if (change === null) continue + const path = change['path'] + if (typeof path !== 'string' || path === '') continue + const action = change['action'] + const digest = change['digest'] + changes.push({ + action: typeof action === 'string' ? action : '', + path, + ...typeof digest === 'string' ? { digest } : {}, + }) + } + return changes +} + +/** + * `instructions` form: the files this context reconciled, then their text. + * + * The text keeps its `` framing verbatim — the framing is part + * of what the model read, so hiding it would misreport the request. + * @param props - Durable content, its source, and the locale seat. + * @returns The instructions context body. + */ +export function InstructionsBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const changes = instructionChanges(source) + const baseline = asRecord(source)?.['baseline'] === true + const { text, rest } = partitionContent(content) + return ( + <> + {changes.length > 0 && ( +
    + {changes.map(change => ( +
  • + {change.path} + + {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)} + +
  • + ))} +
+ )} + {text !== '' &&
{boundedText(text, t)}
} + {rest.map((block, index) => ( + t('json.truncated', { total })} + /> + ))} + + ) +} + +/** One catalog entry, as the durable source records it. */ +interface CatalogEntry { + name: string + description: string +} + +/** Catalog entries read off the source; empty when the shape is unusable. */ +function catalogEntries(source: unknown): CatalogEntry[] { + const record = asRecord(source) + const list = record === null ? undefined : record['entries'] + if (!Array.isArray(list)) return [] + const entries: CatalogEntry[] = [] + for (const item of list) { + const entry = asRecord(item) + if (entry === null) continue + const name = entry['name'] + if (typeof name !== 'string' || name === '') continue + const description = entry['description'] + entries.push({ name, description: typeof description === 'string' ? description : '' }) + } + return entries +} + +/** + * `catalog` form: the published entries as a list, read from the source rather + * than re-parsed out of the model-facing prose. + * + * A catalog whose source carries no usable entries falls through to the opaque + * body, so an older or hand-edited log still shows its text. + * @param props - Durable content, its source, and the locale seat. + * @returns The catalog context body. + */ +export function CatalogBody({ content, source, t }: { + content: ContextMessageNode['content'] + source: unknown + t: Translate +}): ReactNode { + const entries = catalogEntries(source) + if (entries.length === 0) return + return ( +
    + {entries.map(entry => ( +
  • + {entry.name} + {entry.description} +
  • + ))} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css index a15d66efc3..3e8a10b658 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -45,7 +45,6 @@ border-radius: 8px; background: var(--dsw-alias-markdown-code-block); color: var(--dsw-alias-label-tertiary); + /* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */ font: 400 11px/16px var(--ds-font-family-code); - white-space: pre-wrap; - overflow-wrap: anywhere; } diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 31b2092e6e..7d08391f14 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -1,55 +1,19 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatViewSlotProps } from '../contract/slots.ts' import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { DisclosureRow } from './DisclosureRow.tsx' +import { CatalogBody, InstructionsBody, OpaqueBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' -const MAX_CHARS = 20_000 - -function inlineJson(payload: unknown): string { - const raw = JSON.stringify(payload) - let formatted = '' - let quoted = false - let escaped = false - - for (let index = 0; index < raw.length; index++) { - const char = raw.charAt(index) - if (quoted) { - formatted += char - if (escaped) escaped = false - else if (char === '\\') escaped = true - else if (char === '"') quoted = false - continue - } - if (char === '"') { - quoted = true - formatted += char - continue - } - if (char === '{' || char === '[') { - formatted += char - const close = char === '{' ? '}' : ']' - if (raw[index + 1] !== close) formatted += ' ' - continue - } - if (char === '}' || char === ']') { - const open = char === '}' ? '{' : '[' - if (raw[index - 1] !== open) formatted += ' ' - formatted += char - continue - } - formatted += char === ':' || char === ',' ? `${char} ` : char - } - return formatted -} - /** Props for the logged non-user message presentation. */ export interface ContextInjectionRowProps { content: ContextMessageNode['content'] source: ContextMessageNode['source'] /** Role and producer name projected from the durable source. */ provenance: ContextMessageNode['provenance'] + /** Producer-declared information form; null renders the opaque body. */ + form: ContextMessageNode['form'] /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -60,18 +24,13 @@ export interface ContextInjectionRowProps { * The header names the role the context plays and, beside it, the producer the * durable source identifies, so a reader can tell an injected skill catalog * from a workspace instruction file or a recalled session without expanding. - * @param props - Durable content, its projected provenance, and the locale seat. - * @returns A collapsed context row with a bounded JSON body. + * The expanded body follows the producer-declared form; an absent or unknown + * form renders the opaque body. + * @param props - Durable content, its projected provenance and form, and the locale seat. + * @returns A collapsed context row with a bounded, form-specific body. */ -export function ContextInjectionRow({ content, source, provenance, t }: ContextInjectionRowProps) { +export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) { const [open, setOpen] = useState(false) - const body = useMemo(() => { - if (!open) return '' - const text = inlineJson({ content, source }) - return text.length > MAX_CHARS - ? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}` - : text - }, [content, open, source, t]) return ( { setOpen(value => !value) }} > -
{body}
+
+ {form === 'instructions' + ? + : form === 'catalog' + ? + : } +
) } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index b15bd2f987..994cd9ca5c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -257,6 +257,7 @@ export const MessageItem = memo(function MessageItem({ content={node.content} source={node.source} provenance={node.provenance} + form={node.form} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 0043716164..3035856ba8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -55,6 +55,9 @@ export const zh = { 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', + 'message.context.instructions.loaded': '已载入', + 'message.context.instructions.updated': '已更新', + 'message.context.instructions.removed': '已移除', 'message.steering': '插话', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', @@ -170,6 +173,9 @@ export const en = { 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', + 'message.context.instructions.loaded': 'loaded', + 'message.context.instructions.updated': 'updated', + 'message.context.instructions.removed': 'removed', 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 72a7948bdb..2dc328a052 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -227,14 +227,15 @@ describe('MessageItem arms', () => { expect(fork).toHaveBeenCalledWith(2) }) - it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => { + it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => { const ctxView = render( , ) @@ -245,29 +246,84 @@ describe('MessageItem arms', () => { fireEvent.click(disclosure) expect(disclosure.getAttribute('aria-expanded')).toBe('true') - expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe( - '{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], ' - + '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }', - ) + // An unknown form renders the opaque body: the model-facing text keeps its + // real line breaks instead of being escaped into one JSON line, and the + // remaining provenance follows it as fields. + expect(ctxView.container.querySelector('[data-context-text]')?.textContent) + .toBe('line one\n\nline two') + const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) + expect(fields).toEqual(['plugin', 'empty', 'list']) fireEvent.keyDown(disclosure, { key: ' ' }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') }) - it('context preserves the bounded JSON truncation contract', () => { + it('the instructions form names the files it reconciled above their text', () => { const view = render( \nInstructions from: AGENTS.md\n
' }], + source: { + kind: 'workspace-instructions', + form: 'instructions', + baseline: true, + changes: [ + { action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' }, + { action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' }, + ], + }, + provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' }, + form: 'instructions', } as never} />, ) - fireEvent.click(view.getByRole('button', { name: '上下文注入' })) - expect(view.container.querySelector('[data-context-injection-body]')?.textContent) - .toMatch(/… 已截断,共 \d+ 字符$/) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ })) + const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) + expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除']) + // The `` framing is part of what the model read, so the + // body keeps it verbatim rather than presenting a cleaned-up excerpt. + expect(view.container.querySelector('[data-context-text]')?.textContent) + .toContain('') + }) + + it('the catalog form lists its durable entries instead of the model-facing prose', () => { + const view = render( + \n\n- `a`: A\n' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }], + }, + provenance: { role: 'inject', label: 'skill-catalog' }, + form: 'catalog', + } as never} + />, + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent) + expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B']) + expect(view.container.querySelector('[data-context-text]')).toBeNull() + }) + + it('a catalog whose source carries no entries falls back to the opaque body', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) + expect(view.container.querySelector('[data-context-entries]')).toBeNull() + expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') }) it('a recalled session titles its row by role and names the sessions it read', () => { @@ -278,6 +334,7 @@ describe('MessageItem arms', () => { content: [{ type: 'text', text: 'snapshot' }], source: { kind: 'session-reference', version: 1, references: [{ label: '重构 loader' }] }, provenance: { role: 'recall', label: '重构 loader' }, + form: null, } as never} />, ) @@ -293,6 +350,7 @@ describe('MessageItem arms', () => { content: [{ type: 'text', text: 'instructions' }], source: { kind: 'workspace-instructions', changes: [{ path: 'AGENTS.md' }] }, provenance: { role: 'inject', label: 'AGENTS.md' }, + form: null, } as never} />, ) @@ -307,6 +365,7 @@ describe('MessageItem arms', () => { , ) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 3301bf5af6..fef4220777 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -452,6 +452,7 @@ describe('ChatView', () => { const context = { kind: 'context', seq: 4, time: 4_000, content: [], source: null, provenance: { role: 'inject', label: null }, + form: null, } as const satisfies ConversationNode const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) const view = render() diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index 0e63a24a48..2bc230f08c 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -14,6 +14,7 @@ const checkpoint = { content: [], source: { kind: 'plugin', plugin: 'compact' }, provenance: { role: 'inject', label: 'compact' }, + form: null, } as ConversationNode const abandoned = { diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 52dc76070c..71e69eacb3 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -126,6 +126,7 @@ export function apply(ctx: Context, config: Config): void { content: baselineMessage.content, source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [...baseline.changes.values()], }, diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 7bb24a43b2..08dbd4f600 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -39,6 +39,8 @@ const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) /** Durable provenance and reconciliation facts for one workspace context. */ export interface WorkspaceInstructionSource { kind: 'workspace-instructions' + /** Every workspace context carries instructions read out of a file (the `instructions` context form). */ + form: 'instructions' /** Marks the complete startup/resume baseline rather than a later delta. */ baseline?: true changes: WorkspaceInstructionChange[] @@ -87,7 +89,7 @@ export interface ReconciledInstructionContext { function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessage { return createUserMessage({ content: [{ type: 'text', text }], - source: { kind: 'workspace-instructions', changes }, + source: { kind: 'workspace-instructions', form: 'instructions', changes }, }) } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 158d96f24d..3f1b9865a9 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -219,6 +219,7 @@ function workspaceChangeContext(scope: string, digest: string): UserMessage { content: [{ type: 'text', text: `instructions for ${scope}` }], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }], }, }) @@ -974,6 +975,7 @@ describe('workspace context request injection', () => { role: 'user', source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }, @@ -1951,6 +1953,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), @@ -2262,6 +2265,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(changed)?.source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ @@ -2487,6 +2491,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(removed)?.source).toEqual({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ @@ -2887,6 +2892,7 @@ describe('dynamic nested workspace context injection', () => { ], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [ null, { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') }, @@ -3073,6 +3079,7 @@ describe('dynamic nested workspace context injection', () => { expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' }) expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') @@ -3505,7 +3512,7 @@ describe('workspace context pending state', () => { // must not mint per-session pending state. expect(commitPendingInstructionContexts(agent, [createUserMessage({ content: [], - source: { kind: 'workspace-instructions', changes: [] }, + source: { kind: 'workspace-instructions', form: 'instructions', changes: [] }, })], pending)).toEqual([]) expect(pending.has(agent.session)).toBe(false) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 73dd125905..1031a77249 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1809,6 +1809,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContextForm', + declaration: 'export type ContextForm = \'instructions\' | \'catalog\';', + }, + { + name: 'ContextFormed', + declaration: 'export interface ContextFormed {\n readonly form?: ContextForm;\n}', + }, { name: 'ContinuableCreateRequest', declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}', @@ -2135,7 +2143,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'MessageSourceMap', - declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', + declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', }, { name: 'ModelMessageSource', diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index bc351e0598..b6da71f594 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -467,9 +467,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(loadedRequest).toContain('Use the freshly loaded body.') const transcript = handle.agent.session.events.flatMap>((event) => { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') { + if (event.type === 'user/message' && event.data.source.kind === 'skill-catalog') { return [{ type: event.type, source: event.data.source, @@ -503,8 +501,14 @@ describe('dsh-agent-spine-demo bundle', () => { }, { "source": { - "kind": "plugin", - "plugin": "dsh-tool-skill", + "entries": [ + { + "description": "Hot-added skill", + "name": "hot-skill", + }, + ], + "form": "catalog", + "kind": "skill-catalog", }, "text": " A skill is a reusable set of task-specific instructions. The following skills are available in this session: diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 3fa7606c6c..d44a501a60 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -29,13 +29,40 @@ export interface ToolMessageSource { callId: CallId } +/** + * What SHAPE of information a producer-supplied context carries, declared by + * the producer beside its provenance. + * + * `MessageSource.kind` answers *who produced this*; `form` answers *what kind + * of thing it is*, and the two axes are deliberately independent — several + * producers share one form (three snapshot producers today), and one producer + * may emit more than one form over a session. + * + * The vocabulary is SEMANTIC, never visual: a value states that the content is + * a file's instructions or a catalog of available items, and a consumer decides + * what that looks like. Colors, icons, ordering, and collapse defaults are the + * consumer's business and must not enter this union. It grows one value at a + * time as producers gain the structured fields their form needs; an absent or + * unknown value is the documented default, presented as opaque content. + */ +export type ContextForm = + /** Instructions read out of workspace files the model is expected to follow. */ + | 'instructions' + /** A catalog of items available in this session, republished as it changes. */ + | 'catalog' + +/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */ +export interface ContextFormed { + readonly form?: ContextForm +} + /** * Where a message (or injected content) came from. * Merge-extensible sum type — plugins add their own `kind`s. */ export interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 9d706de673..8930f1b6ee 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/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/skill/tool-skill/README.md -README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce -README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3 +README.md: deccda0ece1ffe2cbdb576a8af3801f28695d8d5 +README.zh.md: 67ec0d32759c5768af32b5a505e7a080e19eb396 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index d8e00bc839..deccda0ece 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -10,7 +10,7 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `` before the request. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. -The digest covers the exact rendered text between the `` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. +Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `` framing — written for the model — cannot decide whether a republish is needed, and a consumer presenting the list never re-parses the `` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 6c0df1d6e3..67ec0d3275 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -10,7 +10,7 @@ 每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 ``。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -该 digest 覆盖 `` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 +每条目录消息都携带 `skill-catalog` 来源:一份 `catalog` 形态的上下文,其 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖的是这些持久条目而非渲染出的散文,因此为模型书写的 `` 包装无法左右是否需要重新发布,消费方展示该列表时也不必再解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见的 `skill-catalog` 消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index efc352fd3f..75762a328c 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -22,9 +22,37 @@ export const name = 'tool-skill' export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 -const CATALOG_ENTRIES_START = '\n' -const CATALOG_ENTRIES_END = '' -const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const +/** + * Durable provenance for one published session skill catalog. The catalog is a + * `catalog`-form context, so it records the entries it published beside the + * model-facing prose: a consumer presenting the list must not re-parse the + * `` block, whose framing exists for the model. + */ +export interface SkillCatalogSource { + readonly kind: 'skill-catalog' + readonly form: 'catalog' + /** Marks a replacement catalog rather than this session's first publication. */ + readonly update?: true + /** Exactly the entries this message published, in catalog order. */ + readonly entries: readonly { readonly name: string; readonly description: string }[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'skill-catalog': SkillCatalogSource + } +} + +/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */ +function catalogSourceEntries( + skills: SkillSummary[], + descriptionMaxLength: number, +): SkillCatalogSource['entries'] { + return skills.map(skill => ({ + name: skill.name, + description: catalogDescription(skill.description, descriptionMaxLength), + })) +} /** Model-facing skill catalog configuration. */ export interface Config { @@ -142,13 +170,14 @@ export function apply(ctx: Context, config: Config = {}): void { signal.throwIfAborted() if (!snapshot.complete) return const skills = snapshot.skills.filter(isModelInvocable) - const digest = catalogDigest(skills, catalogDescriptionMaxLength) + const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength) + const digest = digestCatalogEntries(entries) const history = catalogHistory(agent) if (history.visibleDigest === digest) return if (!history.published && skills.length === 0) return const catalog = history.published - ? renderCatalogUpdate(skills, catalogDescriptionMaxLength) - : renderCatalogMessage(skills, catalogDescriptionMaxLength) + ? renderCatalogUpdate(entries) + : renderCatalogMessage(entries) agent.inject(catalog) }) } @@ -199,8 +228,7 @@ function renderResourceHint(skill: Pick', - ...entries, + ...renderCatalogEntries(entries), '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", '', ].join('\n'), }], - source: PLUGIN_SOURCE, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries, + }, }) } -function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage { - const entries = renderCatalogEntries(skills, descriptionMaxLength) - const availability = skills.length === 0 +function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage { + const availability = entries.length === 0 ? [ 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', ] @@ -237,28 +268,36 @@ function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: numbe 'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:', '', '', - ...entries, + ...renderCatalogEntries(entries), '', '', ...availability, '', ].join('\n'), }], - source: PLUGIN_SOURCE, + source: { + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries, + }, }) } -function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] { - return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) +/** Model-facing catalog lines, projected from the same entries the source records. */ +function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] { + return entries.map(entry => `- \`${entry.name}\`: ${entry.description}`) } -function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string { - return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n')) -} - -function digestCatalogEntries(entries: string): string { +/** + * Catalog identity over the durable entry list rather than the rendered prose. + * The entries are what changes; the surrounding `` framing is + * written for the model and must not decide whether a republish is needed. + */ +function digestCatalogEntries(entries: SkillCatalogSource['entries']): string { + const canonical = entries.map(entry => `${entry.name}\u0000${entry.description}`).join('\n') return createHash('sha256') - .update(entries) + .update(canonical) .digest('hex') } @@ -270,30 +309,14 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool // The loop bounds prove the read-only event view contains this index. // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! - if (event.type !== 'user/message' - || event.data.source.kind !== 'plugin' - || event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue - const digest = catalogContentDigest(event.data.content) - if (digest === undefined) continue + if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue + const digest = digestCatalogEntries(event.data.source.entries) published = true if (visible.has(event.seq)) return { visibleDigest: digest, published } } return { published } } -function catalogContentDigest(content: UserMessage['content']): string | undefined { - if (content.length !== 1 || content[0]?.type !== 'text') return undefined - const text = content[0].text - const start = text.indexOf(CATALOG_ENTRIES_START) - if (start === -1) return undefined - const entriesStart = start + CATALOG_ENTRIES_START.length - const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart) - if (end === -1) return undefined - const renderedEntries = text.slice(entriesStart, end) - const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries - return digestCatalogEntries(entries) -} - function catalogDescription(value: string, maxLength: number): string { const normalized = value.replaceAll(/\s+/g, ' ').trim() const truncated = normalized.length <= maxLength diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index df8f4e6fed..b65a890493 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -94,8 +94,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): function catalogMessages(session: Session): Extract[] { return session.events.filter((event): event is Extract => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') + && event.data.source.kind === 'skill-catalog') } function catalogContent(entries: string[]): Message['content'] { @@ -215,7 +214,15 @@ describe('dsh-tool-skill', () => { { id: expect.any(String) as unknown, role: 'user', - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [ + { name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' }, + { name: 'model-only-skill', description: 'Model-only skill.' }, + { name: 'z-skill', description: 'Long description Long description Long descript...' }, + ], + }, content: [{ type: 'text', text: [ @@ -354,7 +361,12 @@ describe('dsh-tool-skill', () => { expect(catalogMessages(session)).toHaveLength(3) }) - it('resumes from the latest valid visible catalog content', async () => { + it('resumes from the durable entries of the latest visible catalog', async () => { + // Catalog identity moved onto `source.entries` when the catalog became a + // `catalog`-form context: the model-facing prose no longer decides whether + // a republish is needed, so a seeded message is recognized by its source + // alone and malformed prose can no longer hide (or fake) a published + // catalog. A foreign-sourced message is not this plugin's catalog at all. const home = await tempDir('tool-catalog-resume') const ctx = await setup(home) ctx.skills.register({ @@ -367,30 +379,36 @@ describe('dsh-tool-skill', () => { const agent = sessionAgent(session) openMessageTurn(session) session.append('user/message', createUserMessage({ - content: catalogContent(['- `old-skill`: Old skill']), - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + content: [{ type: 'text', text: 'prose a reader cannot rely on' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'old-skill', description: 'Old skill' }], + }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'missing catalog markers' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: '\nmissing closing marker' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'reasoning', text: 'not a user-role catalog block' }], + content: catalogContent(['- `resumed-skill`: Resumed skill']), source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) await fireStep(ctx, agent, 1, 1) - expect(catalogMessages(session)).toHaveLength(6) - expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill') + // The seeded entries differ from the live snapshot, so one replacement + // lands; the foreign-sourced lookalike neither counts as published nor + // suppresses it. + expect(catalogMessages(session)).toHaveLength(2) + const latest = catalogMessages(session).at(-1) + expect(latest?.data.source).toMatchObject({ + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries: [{ name: 'resumed-skill', description: 'Resumed skill' }], + }) + expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill') + + // A second step over unchanged entries republishes nothing. + await fireStep(ctx, agent, 1, 2) + expect(catalogMessages(session)).toHaveLength(2) }) it('re-establishes the current catalog after compaction hides its durable message', async () => { From 0073d6aaa19ee569056183cf827e4cc6f24874c7 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 5 Aug 2026 13:54:46 +0800 Subject: [PATCH 042/179] feat(web): turn speed metrics and composer context meter Assistant footers and the stats line gain TTFT/tok-per-second readings folded from step timings; context occupancy moves off the stats line onto a composer ring whose panel shows a heuristic system/tools/messages breakdown from the new token-meter contextBreakdown session projection. --- ...4-web-latency-throughput-metrics.i18n.yaml | 6 + ...26-08-04-web-latency-throughput-metrics.md | 29 +++ ...08-04-web-latency-throughput-metrics.zh.md | 29 +++ ...composer-context-meter-breakdown.i18n.yaml | 6 + ...-08-05-composer-context-meter-breakdown.md | 31 +++ ...-05-composer-context-meter-breakdown.zh.md | 31 +++ docs/cordis-catalog/services.md | 7 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 11 +- docs/core-data-structures/session.zh.md | 11 +- .../client/session-history/history-fold.ts | 46 +---- .../src/client/sessions/assistant-timing.ts | 84 ++++++++ .../src/client/sessions/transcript-adapter.ts | 11 +- .../runtime/tests/transcript-adapter.spec.ts | 44 ++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/chat/AssistantMarkdown.tsx | 8 +- .../src/client/chat/ChatView.tsx | 7 + .../src/client/chat/MessageIconActions.tsx | 20 +- .../src/client/chat/StatsLine.tsx | 84 ++++++-- .../src/client/chat/message-chrome.ts | 21 ++ .../src/client/chat/turn-metrics.ts | 97 +++++++++ .../ui-conversation/src/client/locales.ts | 14 ++ .../client/skeleton/ContextMeter.module.css | 141 +++++++++++++ .../src/client/skeleton/ContextMeter.tsx | 131 ++++++++++++ .../src/client/skeleton/InputBar.tsx | 2 + .../tests/chat-branch-tails.spec.tsx | 9 + .../tests/chat-stats-bash-sample.spec.tsx | 122 +++++++---- .../ui-conversation/tests/chat-view.spec.tsx | 39 ++++ .../tests/context-meter.spec.tsx | 93 +++++++++ .../tests/gate-branch-tails.spec.tsx | 15 +- .../tests/turn-metrics.spec.ts | 154 ++++++++++++++ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/session/src/index.ts | 51 +---- packages/core/session/src/surface.ts | 51 +++++ packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 6 +- packages/llm/token-meter/README.zh.md | 6 +- .../token-meter/src/breakdown-projection.ts | 87 ++++++++ packages/llm/token-meter/src/estimate.ts | 87 ++++++++ packages/llm/token-meter/src/index.ts | 66 +----- packages/llm/token-meter/src/projection.ts | 19 ++ packages/llm/token-meter/src/types.ts | 2 +- .../context-breakdown-projection.spec.ts | 195 ++++++++++++++++++ 45 files changed, 1654 insertions(+), 241 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md create mode 100644 .agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md create mode 100644 .agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md create mode 100644 packages/client/runtime/src/client/sessions/assistant-timing.ts create mode 100644 packages/client/ui-conversation/src/client/chat/turn-metrics.ts create mode 100644 packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx create mode 100644 packages/client/ui-conversation/tests/context-meter.spec.tsx create mode 100644 packages/client/ui-conversation/tests/turn-metrics.spec.ts create mode 100644 packages/llm/token-meter/src/breakdown-projection.ts create mode 100644 packages/llm/token-meter/src/estimate.ts create mode 100644 packages/llm/token-meter/tests/context-breakdown-projection.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml new file mode 100644 index 0000000000..1cf49844ab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.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-04-web-latency-throughput-metrics.md +2026-08-04-web-latency-throughput-metrics.md: f891ce8d2f772c66d5affba2d89694d0eeb681b6 +2026-08-04-web-latency-throughput-metrics.zh.md: e62f5ad6341cffe052118871b57e27f5380c2fd1 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md new file mode 100644 index 0000000000..f891ce8d2f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md @@ -0,0 +1,29 @@ +# Agent Note: Web turn and window latency/throughput metrics + +Status: implemented + +English | [中文](2026-08-04-web-latency-throughput-metrics.zh.md) + +## Problem + +The Web chat records per-step LLM timing (`stepStartTime` / `firstTokenTime` / `completedTime`) and per-step usage, and the trajectory view exposes them per step, but the chat surface answers neither "how responsive was this turn" nor "how fast is this session going": the assistant footer shows only the turn wall time, and the stats line folds only wall-time totals. + +## Decision + +A package-local fold, `ui-conversation`'s `chat/turn-metrics.ts`, is the single derivation from assistant nodes to latency/throughput readings. `assistantStepReading` turns one node into a step reading: TTFT needs both `stepStartTime` and `firstTokenTime`, decode span needs `firstTokenTime`, negative spans clamp to zero, and output tokens come from the untrusted `usage` value only when they are finite and non-negative. `deriveTurnMetrics` folds readings per turn: the lowest-numbered step owns the turn's TTFT slot, and throughput divides the summed output tokens by the summed decode spans over exactly the steps carrying both, so an unsampled step drops out instead of skewing the ratio; a turn with neither figure emits no entry. + +The assistant footer appends the readings to the existing hover-revealed time chrome after `Ran for`, as `TTFT {s}s · {tps} tok/s`, each omitted independently when unrecorded. ChatView shows a turn's readings only when that turn's `turnTimings` entry has an `endTime`: the loaded window is a contiguous log suffix, so an in-window settled turn carries every one of its steps and the first-step TTFT is genuine rather than a window artifact. `formatLatencySeconds` is unit-less so each locale template owns its second suffix (`TTFT {seconds}s` / `首 token {seconds}秒`). + +The stats line reuses the same step reading in its window fold: `deriveStats` accumulates TTFT sum/count and decode span/tokens, rendering a `TTFT avg … · … tok/s` group beside the LLM/tool wall times. Like those wall times the group is window-scoped and folds no billing; token accounting stays on the token-meter projections. + +## Alternatives considered + +**A durable session projection (token-meter shape).** A `ProjectionDefinition` folding step timings host-side would survive compaction and window paging and cover the whole log. Deferred, not rejected: projection state must stay O(1) (averages, not percentiles), it needs a host change plus a schema, and the chat stats line is already documented as window-scoped for its duration facts — the new group joins that scope. A later PR can add the durable projection without moving these readings. + +**Per-step footer chrome.** Showing each assistant message its own TTFT would attach chrome to mid-turn narration nodes, which the footer design deliberately keeps chrome-free; the trajectory view already exposes per-step timing detail. + +**Gating footer metrics on node presence instead of `turn/end` timing.** Rendering whatever steps happen to be loaded would show a plausible-looking TTFT that is actually the first *loaded* step after paging. The `endTime` gate plus the suffix-window invariant makes the displayed figure the turn's true first-step latency or nothing. + +## Consequences + +A settled in-window turn's footer reveals `TTFT`/`tok/s` on hover after the wall time, and the stats line shows window-average latency and throughput beside its wall times, all without new session events or host changes. Metrics degrade by omission: providers or steps without timing or usage samples drop individual figures rather than rendering zeros. Older history outside the loaded window stays uncounted, recorded in the package README's stats-line limitation. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md new file mode 100644 index 0000000000..e62f5ad634 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 轮次与窗口级延迟/吞吐指标 + +Status: implemented + +[English](2026-08-04-web-latency-throughput-metrics.md) | 中文 + +## 问题 + +Web 聊天已经记录了逐步骤的 LLM 计时(`stepStartTime`/`firstTokenTime`/`completedTime`)和逐步骤 usage,trajectory 视图也按步骤展示它们,但聊天界面既回答不了「这一轮响应有多快」,也回答不了「这个会话跑得有多快」:assistant 页脚只显示轮次实际耗时,统计行也只折算墙钟时间总量。 + +## 决策 + +包内折算 `ui-conversation` 的 `chat/turn-metrics.ts` 是从 assistant 节点推导延迟/吞吐读数的唯一位置。`assistantStepReading` 把一个节点转成一次步骤读数:TTFT(首 token 延迟)需要 `stepStartTime` 与 `firstTokenTime` 同时存在,解码时长需要 `firstTokenTime`,负时长收敛为零,输出 token 数只在不可信的 `usage` 值有限且非负时才采纳。`deriveTurnMetrics` 按轮次折算读数:编号最小的步骤拥有该轮次的 TTFT 槽位,吞吐用「同时携带两者的那些步骤」的输出 token 总和除以解码时长总和,因此缺采样的步骤直接退出而不是让比值失真;两个数字都没有的轮次不产生条目。 + +assistant 页脚把读数追加到既有 hover 显示的时间附属元素中、`用时` 之后,形如 `首 token {s}秒 · {tps} tok/s`,未记录的数字各自省略。ChatView 仅在该轮次的 `turnTimings` 条目带有 `endTime` 时才显示读数:已加载窗口是日志的连续后缀,因此窗口内已结算的轮次必然带着它的全部步骤,首步 TTFT 是真实值而非窗口截断的产物。`formatLatencySeconds` 不带单位,各语言模板各自拥有秒后缀(`TTFT {seconds}s`/`首 token {seconds}秒`)。 + +统计行在其窗口折算中复用同一份步骤读数:`deriveStats` 累计 TTFT 总和/计数与解码时长/token 数,在 LLM/工具墙钟时间旁渲染 `TTFT avg … · … tok/s` 分组。与那些墙钟时间一样,该分组是窗口作用域的,不折算任何计费;token 账目仍归 token-meter 投影。 + +## 考虑过的替代方案 + +**持久的会话投影(token-meter 形态)。** 在 host 侧用 `ProjectionDefinition` 折算步骤计时可以跨越压缩与窗口分页、覆盖整个日志。是暂缓而非否决:投影状态必须保持 O(1)(只能均值,不能分位数),它需要 host 改动加 schema,而聊天统计行的耗时事实本就被记录为窗口作用域——新分组沿用该作用域。后续 PR 可以在不挪动这些读数的情况下补上持久投影。 + +**逐步骤页脚附属元素。** 让每条 assistant 消息显示自己的 TTFT,会给轮次中段的叙述节点挂上附属元素,而页脚设计刻意让它们保持无 chrome;trajectory 视图已经暴露逐步骤计时细节。 + +**用节点是否在场而非 `turn/end` 计时做页脚门控。** 直接渲染碰巧加载到的步骤,会展示一个貌似合理、实为分页后「首个已加载步骤」的 TTFT。`endTime` 门控加上后缀窗口不变量,使显示的数字要么是该轮次真实的首步延迟,要么什么都不显示。 + +## 后果 + +窗口内已结算轮次的页脚在 hover 时于实际耗时之后显示 `首 token`/`tok/s`,统计行在墙钟时间旁显示窗口平均延迟与吞吐,全程不新增会话事件、不改 host。指标以省略的方式退化:没有计时或 usage 采样的提供方或步骤只是丢掉对应数字,而不会渲染成零。已加载窗口之外的更早历史仍不计入,已记录在包 README 的统计行限制中。 diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml new file mode 100644 index 0000000000..c0727e7f3b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.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-05-composer-context-meter-breakdown.md +2026-08-05-composer-context-meter-breakdown.md: 2879030aa7859385b2e6026fbf87da4548c392bd +2026-08-05-composer-context-meter-breakdown.zh.md: 3aeff68ebd157c4104fc085ad19e1738ead8f983 diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md new file mode 100644 index 0000000000..2879030aa7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md @@ -0,0 +1,31 @@ +# Agent Note: Composer context meter with heuristic composition breakdown + +Status: implemented + +English | [中文](2026-08-05-composer-context-meter-breakdown.zh.md) + +## Problem + +The Web chat's stats line showed context occupancy as one inline figure (`Context N% of X`) among its billing groups. That answers "how full" but not "what fills it": nothing showed how the window divides between the system prompt, tool schemas, and conversation, and the one-line row has no room for that detail. The available numbers also live in two vocabularies — the provider-exact billed prompt size from `contextPressure` versus the token-meter's fixed character heuristic — and no existing surface could present composition without conflating them. + +## Decision + +Three cooperating pieces, one per package boundary: + +`dsh-session` exports the pure `deriveEventMessage(event)` (previously reachable only as a `Session` method, which now delegates to it) so a host-side fold can price surface nodes without a `Session` instance. + +`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` — shared verbatim by the measurement service — and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure folds surface appends and positional replacements over a per-node `{seq, tokens}` list, so compaction shrinks it the same way it shrinks the next request. A replace range absent from the folded surface throws: committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event. + +`ui-conversation` moves context occupancy off the stats line (one home per fact) onto a composer-trailing `ContextMeter`: a 14px occupancy ring after the model seat fed by `contextPressure`, click-opening a panel that pairs the provider-exact percent and `~used / capacity` header with a 4px color-segmented bar and `~`-prefixed composition rows. The two vocabularies deliberately never reconcile — the ring, header, and bar length stay provider-exact while the heuristic shares only proportion the bar's colored segments and rows, each marked `~` because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code. + +## Alternatives considered + +**Deriving composition client-side from the loaded window.** The window is a contiguous log suffix: the `request/header` events carrying the system prompt and tool schemas may sit outside it, and paging would silently change the figures. Only a durable host-side projection survives paging and compaction, which is why the data crosses the wire as a third projection rather than a chat-window fold. + +**Scaling the heuristic rows to sum to `pressureTokens`.** Forced reconciliation fabricates precision: pressure lags one request, includes provider envelope overhead the estimator never models, and would make the rows move when nothing in the composition changed. Showing the estimator's real vocabulary with an explicit `~` was chosen instead. + +**Finer categories (rules, skills, MCP tools) as in Claude Code's `/context`.** Not separable here: the harness folds those contributions into the system text and the tools list before the request header exists, so three categories are the honest resolution. + +## Consequences + +Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 1). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token. diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md new file mode 100644 index 0000000000..3aeff68ebd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md @@ -0,0 +1,31 @@ +# Agent Note: composer 上下文占用圆环与启发式组成明细 + +Status: implemented + +[English](2026-08-05-composer-context-meter-breakdown.md) | 中文 + +## 问题 + +Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N% of X`)挤在计费分组之间。它回答了「有多满」,却回答不了「被什么占满」:没有任何地方展示窗口在系统提示词、工具 schema 与对话之间如何分配,而单行统计行也容纳不下这种明细。可用的数字还分属两套口径——来自 `contextPressure` 的提供方精确计费 prompt 规模,与 token-meter 的固定字符启发式——没有任何既有界面能在不混淆两者的前提下展示组成。 + +## 决定 + +三个协作部分,每个包边界一个: + +`dsh-session` 导出纯函数 `deriveEventMessage(event)`(此前只能通过 `Session` 方法访问,该方法现在委托给它),使 host 侧 fold 无需 `Session` 实例即可为表层节点计价。 + +`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`(与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字用逐节点 `{seq, tokens}` 列表折叠表层追加与位置替换,因此压缩会像缩小下一个请求那样缩小它。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。 + +`ui-conversation` 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 `ContextMeter`:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,点击弹出的面板把提供方精确的百分比与 `~已用 / 容量` 标题,与 4px 分色分段进度条及带 `~` 前缀的组成明细行并列。两套口径刻意永不对账——圆环、标题与进度条总长保持提供方精确值,启发式占比只用于切分进度条的彩色分段与明细行,且每个启发式数字都标 `~`,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。 + +## 备选方案 + +**在客户端从已加载窗口推导组成。** 窗口是日志的连续后缀:携带系统提示词与工具 schema 的 `request/header` 事件可能在窗口之外,翻页还会让数字悄悄变化。只有持久的 host 侧投影能在翻页与压缩后幸存,这正是数据以第三个投影而非聊天窗口 fold 的形式过线的原因。 + +**把启发式明细行按比例缩放到与 `pressureTokens` 相加一致。** 强行对账是在捏造精度:压力滞后一个请求,还包含估算器从不建模的提供方封装开销,会让明细行在组成毫无变化时也跟着变动。最终选择以显式 `~` 展示估算器的真实口径。 + +**更细的类别(rules、skills、MCP 工具,如 Claude Code 的 `/context`)。** 在这里不可分:harness 在请求标头存在之前就把这些贡献折入系统文本与工具列表,因此三个类别是诚实的分辨率。 + +## 后果 + +token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 1)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cb8cb3b3b2..77ab7ffbe0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1681,7 +1681,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:767`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:726`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -2307,7 +2307,8 @@ Replay owner for one service-wide estimator and isolated per-session folds. measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement /** - * Heuristically price one model-visible message. + * Heuristically price one model-visible message (instance face of the pure + * {@link estimateMessage}). * @param message - message to price without mutation. * @returns content and role-framing tokens under the fixed service heuristic. */ @@ -2316,7 +2317,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:85`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:78`](../../packages/llm/token-meter/src/index.ts) ## `ctx.toolResultPrune` — `ToolResultPruneService` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 773df07254..fd0e1e9d9b 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 6369c956df03c0d786db696c208b000300d5bfbc -session.zh.md: c39382b7e6c9b14f91c311cc80526a6fd8898e4c +session.md: 0d32b47585e01dd992167b6580245c5444f3891b +session.zh.md: ad3fb474c4f9c68ef33bf9f1ddea0a1a64b31de6 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6369c956df..0d32b47585 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -487,15 +487,8 @@ declare class Session { */ deriveMessages(): Message[]; /** - * Project a single event into the LLM message it derives to, or null when - * it produces none — a non-surface event (chunk, boundary, log-only record) - * or an empty-content assistant/message (which exists only to host usage). - * The per-node pure function {@link deriveMessages} folds over the surface; - * an external reconstructor (or the dev invariant) folds the same function - * over a log prefix's surface to rebuild the exact messages any request was - * built from (the reconstructability Agent Note). The returned message is - * the already frozen message nested in the event wrapper and shared by - * delivery, durable history, and model requests. + * Instance face of the pure per-node projection rule + * {@link deriveEventMessage} (see its contract in `surface.ts`). * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index c39382b7e6..ad3fb474c4 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -489,15 +489,8 @@ declare class Session { */ deriveMessages(): Message[]; /** - * Project a single event into the LLM message it derives to, or null when - * it produces none — a non-surface event (chunk, boundary, log-only record) - * or an empty-content assistant/message (which exists only to host usage). - * The per-node pure function {@link deriveMessages} folds over the surface; - * an external reconstructor (or the dev invariant) folds the same function - * over a log prefix's surface to rebuild the exact messages any request was - * built from (the reconstructability Agent Note). The returned message is - * the already frozen message nested in the event wrapper and shared by - * delivery, durable history, and model requests. + * Instance face of the pure per-node projection rule + * {@link deriveEventMessage} (see its contract in `surface.ts`). * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index c4bc6ed9b5..c52bc17794 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -16,6 +16,8 @@ import type { } from '../sessions/conversation-context.ts' import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts' import { PartialAccumulator } from '../sessions/partial.ts' +import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts' +import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts' interface CallIndexEntry { name: string @@ -30,11 +32,6 @@ interface FoldedContext { originSeq?: number } -interface AssistantStepMetadata { - stepStartTime: number | null - firstTokenTime: number | null -} - /** Immutable conversation projections derived only from the history source. */ export interface ConversationHistoryProjection { eventNodes: readonly ConversationNode[] @@ -45,10 +42,6 @@ export interface ConversationHistoryProjection { codeDispatches: ReadonlyMap } -function assistantStepKey(turn: number, step: number): string { - return `${turn}\u0000${step}` -} - // Trajectory owns surface-window reconstruction so its immutable ledger does // not depend on Chat's live fold adapter or Session's mutable state. /* jscpd:ignore-start */ @@ -72,18 +65,6 @@ function contextOriginKind(event: SessionEvent | undefined): ConversationContext return 'rewrite' } -function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { - switch (chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return chunk.text !== '' - case 'tool-call-delta': - return chunk.argumentsDelta !== '' || chunk.name !== undefined - default: - return false - } -} - function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { const replay: SessionEvent[] = [] const surface = new SurfaceManager(replay) @@ -362,6 +343,7 @@ export function projectConversationHistory( contextGeneration++ if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt) } + indexAssistantStepTiming(assistantSteps, event) if (event.type === 'request/header') { activeRequestConfig = event.data.header.config activePrompt = { @@ -370,30 +352,10 @@ export function projectConversationHistory( tools: event.data.header.tools ?? [], } promptsByContext.set(contextGeneration, activePrompt) - } else if (event.type === 'step/start') { - assistantSteps.set( - assistantStepKey(event.data.turn, event.data.step), - { stepStartTime: event.time, firstTokenTime: null }, - ) - } else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) { - const key = assistantStepKey(event.data.turn, event.data.step) - const current = assistantSteps.get(key) ?? { - stepStartTime: null, - firstTokenTime: null, - } - if (current.firstTokenTime === null) { - assistantSteps.set(key, { ...current, firstTokenTime: event.time }) - } } else if (event.type === 'assistant/message') { assistantTimings.set( event.seq, - { - ...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? { - stepStartTime: null, - firstTokenTime: null, - }), - completedTime: event.time, - }, + settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time), ) if (activeRequestConfig !== undefined) { assistantRequestConfigs.set(event.seq, activeRequestConfig) diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/runtime/src/client/sessions/assistant-timing.ts new file mode 100644 index 0000000000..021c679d04 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/assistant-timing.ts @@ -0,0 +1,84 @@ +// Shared assistant step-timing fold: both transcript projections (the live +// window adapter and the trajectory history fold) derive AssistantTiming from +// the same step/start -> first token delta -> assistant/message sequence, so +// the derivation lives once here instead of drifting per projection. + +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { AssistantTiming } from './conversation.ts' + +/** Pre-finalize timing boundaries for one assistant step (start + first token). */ +export interface AssistantStepMetadata { + stepStartTime: number | null + firstTokenTime: number | null +} + +/** + * Composite map key for one assistant step. + * @param turn - turn number from the event payload. + * @param step - step number from the event payload. + * @returns collision-free `turn`/`step` key (NUL separator). + */ +export function assistantStepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +/** + * Whether a chunk carries visible model output (first-token boundary). Empty + * deltas (heartbeats, empty tool-call frames) do not count as a first token. + * @param chunk - the assistant/chunk payload. + * @returns true when the chunk contains a non-empty text/reasoning/tool delta. + */ +export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} + +/** + * Fold one event into the per-step timing index: step/start opens the entry, + * the first non-empty token delta stamps first-token time once. Other event + * types are no-ops. + * @param steps - the mutable per-step index, keyed by {@link assistantStepKey}. + * @param event - the raw window event. + */ +export function indexAssistantStepTiming(steps: Map, event: SessionEvent): void { + if (event.type === 'step/start') { + steps.set( + assistantStepKey(event.data.turn, event.data.step), + { stepStartTime: event.time, firstTokenTime: null }, + ) + } else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) { + const key = assistantStepKey(event.data.turn, event.data.step) + const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null } + if (current.firstTokenTime === null) { + steps.set(key, { ...current, firstTokenTime: event.time }) + } + } +} + +/** + * Settle one finalized assistant message's timing from its step entry; a step + * whose start or first token fell outside the window yields null boundaries. + * @param steps - the per-step index built by {@link indexAssistantStepTiming}. + * @param turn - the assistant/message turn number. + * @param step - the assistant/message step number. + * @param completedTime - the assistant/message event timestamp (epoch ms). + * @returns the node-ready timing record. + */ +export function settledAssistantTiming( + steps: ReadonlyMap, + turn: number, + step: number, + completedTime: number, +): AssistantTiming { + return { + ...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }), + completedTime, + } +} diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 05ac3cb067..a59abd03f5 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -22,6 +22,8 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' +import type { AssistantStepMetadata } from './assistant-timing.ts' +import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts' /** * The compaction seam's checkpoint plugin, pinned to the seam's own declaration @@ -50,6 +52,7 @@ function materializeNode( event: SessionEvent, callIndex: ReadonlyMap, resultView: ToolResultView | null, + stepTimings: ReadonlyMap, ): ConversationNode { switch (event.type) { case 'user/message': @@ -71,6 +74,7 @@ function materializeNode( kind: 'assistant', seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, + timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time), } case 'steering/message': return { @@ -177,6 +181,8 @@ export class TranscriptAdapter { /** Transcript nodes in log order; copy-on-write so a published array never mutates. */ private projected: ConversationNode[] = [] private callIdx = new Map() + /** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */ + private stepTimings = new Map() /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */ private resultViews = new Map() /** @@ -207,6 +213,7 @@ export class TranscriptAdapter { this.callIdx = new Map() this.resultViews.clear() this.commandIdx = new Map() + this.stepTimings = new Map() for (let i = 0; i < events.length; i++) { const event = events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -214,6 +221,7 @@ export class TranscriptAdapter { this.eventIndex.set(event.seq, event) this.indexCall(event, views?.[i]) this.indexCommand(event) + indexAssistantStepTiming(this.stepTimings, event) } // Indexes first, then project: a tool/result materializes against the // complete call index, and a checkpoint against the complete event index. @@ -236,6 +244,7 @@ export class TranscriptAdapter { append(event: SessionEvent, view?: ToolEventView): void { this.eventIndex.set(event.seq, event) this.indexCall(event, view) + indexAssistantStepTiming(this.stepTimings, event) if (this.indexCommand(event)) this.rev++ if (!isTranscriptEvent(event)) return this.projected = [...this.projected, this.materialize(event)] @@ -274,7 +283,7 @@ export class TranscriptAdapter { private materialize(event: SessionEvent): ConversationNode { return isCompactCheckpoint(event) ? materializeCompaction(event, this.eventIndex) - : materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null) + : materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null, this.stepTimings) } /** diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index cc03d349b8..352f85cee1 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -415,4 +415,48 @@ describe('TranscriptAdapter', () => { expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } }) }) }) + + describe('assistant timing', () => { + const base = 1_700_000_000_000 + + it('derives step timing across a window rebuild (start + first token + completion)', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ + ev.turnStart(0, 0), + ev.user(1, '问'), + ev.stepStart(2, 0), + ev.chunkStart(3, 0), + ev.chunkText(4, 0, '答'), + ev.chunkText(5, 0, '案'), + ev.assistant(6, 0, '答案'), + ev.turnEnd(7, 0), + ]) + const assistant = adapter.nodes().find(n => n.kind === 'assistant') + expect(assistant).toMatchObject({ + timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 }, + }) + }) + + it('derives the same timing on the live append path, first token winning once', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ev.user(0, '问')]) + adapter.append(ev.stepStart(1, 0)) + adapter.append(ev.chunkText(2, 0, '首')) + adapter.append(ev.chunkText(3, 0, '次')) + adapter.append(ev.assistant(4, 0, '首次')) + const assistant = adapter.nodes().find(n => n.kind === 'assistant') + expect(assistant).toMatchObject({ + timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 }, + }) + }) + + it('soft-falls to null boundaries when the step opening fell outside the window', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ev.assistant(100, 0, '被切窗的答案')]) + const assistant = adapter.nodes().find(n => n.kind === 'assistant') + expect(assistant).toMatchObject({ + timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 }, + }) + }) + }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..7401962fea 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: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: 737a53da1fa4541f81d89b41945cacd2b415ef4a +README.zh.md: 800d2b3e762d410100c8c9eb64bfbcdb22cda62a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..737a53da1f 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -46,7 +46,7 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. -The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory. +The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a `TTFT avg … · … tok/s` group; a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both provider pressure and route capacity are known, that click-opens a panel pairing the provider-exact `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection — the exact and heuristic vocabularies deliberately do not reconcile. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). @@ -61,7 +61,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. -- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. +- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). - **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..800d2b3e76 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -46,7 +46,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。 +聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到 `TTFT avg … · … tok/s` 分组;缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当提供方压力与路由容量都已知时才渲染;点击弹出的面板把提供方精确的「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列——精确口径与启发式口径刻意不做对账。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 @@ -61,7 +61,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu ## 已知限制与暂缓事项 - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 -- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 +- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 - **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index de882e8bb2..5b3b9fa821 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -30,6 +30,10 @@ export interface AssistantMarkdownProps { /** Turn wall time in ms for the IconActions run-time label; omitted when the * turn's triggering input is outside the loaded window. */ runMs?: number | undefined + /** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */ + ttftMs?: number | undefined + /** Turn decode throughput for the IconActions label; omitted when unrecorded. */ + tokensPerSecond?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ @@ -82,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -125,6 +129,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ text={copyText(blocks)} time={time} runMs={runMs} + ttftMs={ttftMs} + tokensPerSecond={tokensPerSecond} clock="end" onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} branchUnavailable={forkUnavailable} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 3320eb0f69..c852161240 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -36,6 +36,7 @@ import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' import { formatRunDuration } from './message-chrome.ts' +import { deriveTurnMetrics } from './turn-metrics.ts' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 @@ -362,6 +363,7 @@ export function ChatView({ const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) + const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) const listRef = useRef(null) const columnRef = useRef(null) @@ -599,6 +601,9 @@ export function ChatView({ const node: ConversationNode = item.node if (node.kind === 'assistant') { const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined + // Metrics gate on the settled in-window timing: turn/start loaded means + // every step of the turn is loaded, so first-step TTFT is genuine. + const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn) return ( )} + {ttftMs !== undefined && ( + <> + · + {t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })} + + )} + {tokensPerSecond !== undefined && ( + <> + · + {t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })} + + )} ) return ( diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index ceffffb59a..d30c8dbf0e 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -2,10 +2,13 @@ // Mounted on 'conversation.composer.dock' so it sticks with the composer in the // active conversation scrollport (see ConversationRoot data-conversation-scroll). -import { Fragment, memo, useMemo } from 'react' +import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' +import { formatTokensPerSecond } from './message-chrome.ts' +import { assistantStepReading } from './turn-metrics.ts' import css from './StatsLine.module.css' interface WindowStats { @@ -15,6 +18,14 @@ interface WindowStats { llmMs: number /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */ toolMs: number + /** Summed first-token latency over `ttftSteps`; 0 when no step records it. */ + ttftMs: number + /** Steps carrying a recorded TTFT. */ + ttftSteps: number + /** Summed decode wall time over steps that also report output tokens. */ + decodeMs: number + /** Summed output tokens over the same decode-timed steps. */ + decodeTokens: number } /** @@ -32,6 +43,10 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats { let steps = 0 let llmMs = 0 let toolMs = 0 + let ttftMs = 0 + let ttftSteps = 0 + let decodeMs = 0 + let decodeTokens = 0 for (const node of nodes) { if (node.kind === 'tool-result') { if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime) @@ -43,8 +58,17 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats { if (node.timing !== undefined && node.timing.stepStartTime !== null) { llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime) } + const reading = assistantStepReading(node) + if (reading.ttftMs !== null) { + ttftMs += reading.ttftMs + ttftSteps += 1 + } + if (reading.decodeMs !== null && reading.outputTokens !== null) { + decodeMs += reading.decodeMs + decodeTokens += reading.outputTokens + } } - return { turns: turns.size, steps, llmMs, toolMs } + return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens } } /** @@ -84,13 +108,18 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null { : Math.round(usage.cacheReadTokens / denominator * 100) } -/** Sum the three disjoint prompt-side billing buckets. */ -function billedInputTokens(usage: TokenUsageProjection): number { +/** + * Sum the three disjoint prompt-side billing buckets. + * @param usage - the session's token-usage projection value. + * @returns billed input tokens. + */ +export function billedInputTokens(usage: TokenUsageProjection): number { return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens } interface ContextOccupancy { percent: number + pressureTokens: number contextWindow: number } @@ -100,7 +129,7 @@ interface ContextOccupancy { * fields, so this is a reference figure rather than an exact measurement of one * request (see the token-meter README). * @param pressure - the session's context-pressure projection value. - * @returns occupancy and its denominator, or null until both values are known. + * @returns occupancy with its numerator and denominator, or null until both values are known. */ export function contextOccupancy( pressure: ContextPressureProjection | undefined, @@ -108,6 +137,7 @@ export function contextOccupancy( if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null return { percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)), + pressureTokens: pressure.pressureTokens, contextWindow: pressure.contextWindow, } } @@ -121,7 +151,6 @@ export interface StatsLineProps { export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) { const nodes = useSession(s => s.nodes) const usage = useProjection('tokenUsage') - const pressure = useProjection('contextPressure') const stats = useMemo(() => deriveStats(nodes), [nodes]) // Pipe-separated groups (figma stats strip); a group with no data drops out whole. const groups: string[] = [] @@ -131,11 +160,14 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }: if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`) if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`) if (durations.length > 0) groups.push(durations.join(' · ')) + // Window-scoped like the wall times above: averages describe loaded steps. + const speeds: string[] = [] + if (stats.ttftSteps > 0) speeds.push(`TTFT avg ${formatDuration(stats.ttftMs / stats.ttftSteps)}`) + if (stats.decodeMs > 0) speeds.push(`${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000))} tok/s`) + if (speeds.length > 0) groups.push(speeds.join(' · ')) } - const context = contextOccupancy(pressure) - if (context !== null) { - groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`) - } + // Context occupancy deliberately lives on the composer's ContextMeter ring, + // not here — one home per fact. // Billing rides the durable projection, so these survive paging and // compaction. Suppress the empty projection on a brand-new session. if (usage !== undefined @@ -147,15 +179,31 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }: + ` · Output ${formatTokens(usage.outputTokens)} tok`, ) } + const line = groups.join(' | ') + // The row elides with ellipsis when overlong; a delayed hover tooltip carries + // the full line, enabled only while content is actually clipped. + const rootRef = useRef(null) + const [truncated, setTruncated] = useState(false) + useLayoutEffect(() => { + const el = rootRef.current + if (el === null) return + const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) } + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => { observer.disconnect() } + }, [line]) if (groups.length === 0) return null return ( -
- {groups.map((group, i) => ( - - {i > 0 && <>|{' '}} - {group} - - ))} -
+ +
+ {groups.map((group, i) => ( + + {i > 0 && <>|{' '}} + {group} + + ))} +
+
) }) diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts index 5837957011..a3658853c9 100644 --- a/packages/client/ui-conversation/src/client/chat/message-chrome.ts +++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts @@ -48,6 +48,27 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string { : t('duration.seconds', { seconds }) } +/** + * Sub-turn latency figure: one decimal under ten seconds, whole seconds + * beyond. Unit-less so the locale template owns the second suffix. + * @param ms - Latency in milliseconds (negatives clamp to zero). + * @returns Display number in seconds without unit. + */ +export function formatLatencySeconds(ms: number): string { + const s = Math.max(0, ms) / 1000 + return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s)) +} + +/** + * Decode-throughput figure: whole tokens from ten up, one decimal below. + * @param tps - Tokens per second. + * @returns Display number without unit. + */ +export function formatTokensPerSecond(tps: number): string { + const clamped = Math.max(0, tps) + return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10) +} + /** * Compact local timestamp for message IconActions. Same calendar day → * `HH:mm`; earlier this year → the `clock.md` date template + clock; other diff --git a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts new file mode 100644 index 0000000000..156a4c0e9d --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts @@ -0,0 +1,97 @@ +// Latency/throughput folds shared by the settled turn footer and StatsLine. + +import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' + +/** Latency and decode-throughput readings for one turn's footer. */ +export interface TurnMetrics { + /** First-step TTFT in ms; absent when that step carries no recorded timing. */ + ttftMs?: number + /** Decode throughput over steps carrying both timing and provider usage. */ + tokensPerSecond?: number +} + +/** One assistant step's derivable latency facts; null marks an unrecorded part. */ +export interface StepReading { + /** step/start → first token delta, in ms. */ + ttftMs: number | null + /** First token delta → final message, in ms. */ + decodeMs: number | null + /** Provider-reported completion tokens. */ + outputTokens: number | null +} + +interface UsageLike { + outputTokens?: number +} + +type AssistantNode = Extract + +function usageOutputTokens(usage: unknown): number | null { + if (typeof usage !== 'object' || usage === null) return null + const value = (usage as UsageLike).outputTokens + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null +} + +/** + * Read one assistant node's TTFT, decode wall time, and output tokens. + * @param node - A settled assistant node. + * @returns Per-part readings with `null` for unrecorded values. + */ +export function assistantStepReading(node: AssistantNode): StepReading { + const timing = node.timing + const ttftMs = timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null + ? Math.max(0, timing.firstTokenTime - timing.stepStartTime) + : null + const decodeMs = timing !== undefined && timing.firstTokenTime !== null + ? Math.max(0, timing.completedTime - timing.firstTokenTime) + : null + return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) } +} + +interface TurnFold { + firstStep: number + firstStepTtftMs: number | null + decodeMs: number + outputTokens: number + sampled: boolean +} + +/** + * Fold assistant nodes into per-turn footer metrics. + * + * TTFT is the turn's lowest-step reading — the user-perceived wait before + * output appeared — so it is only meaningful when the turn's start is inside + * the loaded window (the caller gates on `turnTimings`, which shares that + * window). Throughput divides summed output tokens by summed decode wall time, + * counting only steps that carry both. + * @param nodes - Snapshot nodes of the loaded window. + * @returns Turn number → available metrics; turns with none are absent. + */ +export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map { + const folds = new Map() + for (const node of nodes) { + if (node.kind !== 'assistant') continue + const reading = assistantStepReading(node) + let fold = folds.get(node.turn) + if (fold === undefined) { + fold = { firstStep: node.step, firstStepTtftMs: reading.ttftMs, decodeMs: 0, outputTokens: 0, sampled: false } + folds.set(node.turn, fold) + } else if (node.step < fold.firstStep) { + fold.firstStep = node.step + fold.firstStepTtftMs = reading.ttftMs + } + if (reading.decodeMs !== null && reading.outputTokens !== null) { + fold.decodeMs += reading.decodeMs + fold.outputTokens += reading.outputTokens + fold.sampled = true + } + } + const metrics = new Map() + for (const [turn, fold] of folds) { + const entry: TurnMetrics = {} + if (fold.firstStepTtftMs !== null) entry.ttftMs = fold.firstStepTtftMs + if (fold.sampled && fold.decodeMs > 0) entry.tokensPerSecond = fold.outputTokens / (fold.decodeMs / 1000) + if (entry.ttftMs !== undefined || entry.tokensPerSecond !== undefined) metrics.set(turn, entry) + } + return metrics +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index a9a4f9544e..344b2b64c7 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,6 +23,11 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', + 'context.aria': '上下文已用 {percent}%', + 'context.used': '上下文已用', + 'context.system': '系统提示词', + 'context.tools': '工具', + 'context.messages': '对话消息', 'settings.enter.title': '繁忙时 Enter 键行为', 'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为', 'settings.enter.queue': '排队发送', @@ -71,6 +76,8 @@ export const zh = { 'message.retry.failure': '失败原因:', 'message.turnError': '本轮运行失败', 'message.ranFor': '用时 {duration}', + 'message.ttft': '首 token {seconds}秒', + 'message.tokensPerSecond': '{tps} tok/s', 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'command.running': '执行中…', @@ -136,6 +143,11 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', + 'context.aria': '{percent}% of context used', + 'context.used': 'of context used', + 'context.system': 'System prompt', + 'context.tools': 'Tools', + 'context.messages': 'Messages', 'settings.enter.title': 'Enter behavior while busy', 'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior', 'settings.enter.queue': 'Queue', @@ -184,6 +196,8 @@ export const en = { 'message.retry.failure': 'Failure reason: ', 'message.turnError': 'This turn failed', 'message.ranFor': 'Ran for {duration}', + 'message.ttft': 'TTFT {seconds}s', + 'message.tokensPerSecond': '{tps} tok/s', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'command.running': 'Running…', diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css new file mode 100644 index 0000000000..9ded4d91c2 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css @@ -0,0 +1,141 @@ +/* Context-occupancy ring beside the send button plus its click-open breakdown + panel (menu surface: r12, inverted hairline, shadow-lv3). */ + +.root { + position: relative; + display: inline-flex; +} + +/* Same 28px circular hit target family as the composer's attach button. */ +.trigger { + display: grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.trigger:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.track { + fill: none; + stroke: var(--dsw-alias-border-l3); + stroke-width: 2; +} + +.fill { + fill: none; + stroke: var(--dsw-alias-label-tertiary); + stroke-width: 2; + stroke-linecap: round; +} + +.panel { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + z-index: 100; + box-sizing: border-box; + width: 264px; + padding: 12px; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + cursor: default; +} + +.header { + display: flex; + align-items: center; + gap: 6px; +} + +.figures { + margin-left: auto; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-primary); +} + +.percent { + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.headline { + color: var(--dsw-alias-label-tertiary); +} + +.bar { + display: flex; + gap: 1px; + margin: 10px 0 12px; + height: 4px; + border-radius: 999px; + background: var(--dsw-alias-interactive-bg-hover); + overflow: hidden; +} + +.segment { + flex: none; + min-width: 2px; + height: 100%; + border-radius: 1px; + background: var(--meter-tint, var(--dsw-alias-label-tertiary)); +} + +.swatch { + display: inline-block; + margin-right: 6px; + width: 8px; + height: 8px; + border-radius: 2px; + background: var(--meter-tint); + vertical-align: baseline; +} + +.colorSystem { + --meter-tint: var(--dsw-static-neutral-bluish-400); +} + +.colorTools { + /* The design platform ships no purple static token; violet-400 literal. */ + --meter-tint: rgb(167, 139, 250); +} + +.colorMessages { + --meter-tint: var(--dsw-static-blue-450); +} + +.rows { + margin: 6px 0 0; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 2px 0; +} + +.row dt { + color: var(--dsw-alias-label-secondary); +} + +.row dd { + margin: 0; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-primary); +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx new file mode 100644 index 0000000000..1ce40d3e87 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx @@ -0,0 +1,131 @@ +/** Composer context-occupancy meter: a ring beside the send button fed by the + * `contextPressure` projection, with a click-open panel of the heuristic + * `contextBreakdown` composition (system prompt, tools, conversation). + * Renders nothing until a provider reports both pressure and a route capacity + * (same gate as the stats row used). */ + +import { useEffect, useRef, useState } from 'react' +import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the `contextPressure` / `contextBreakdown` projection key merges. +import type {} from '@deepseek-ai/dsh-token-meter/client' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ComposerBarProps } from '../contract/slots.ts' +import { contextOccupancy, formatTokens } from '../chat/StatsLine.tsx' +import css from './ContextMeter.module.css' + +/** Ring geometry: 14px viewBox, 2px stroke. */ +const RADIUS = 5.5 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +/** Panel legend rows, in bar-segment order; each color class carries the shared swatch/segment tint. */ +const ROWS = [ + { key: 'systemTokens', label: 'context.system', color: css.colorSystem }, + { key: 'toolsTokens', label: 'context.tools', color: css.colorTools }, + { key: 'messageTokens', label: 'context.messages', color: css.colorMessages }, +] as const + +export interface ContextMeterProps { + useProjection: UseProjection + /** The owning bar's locale seat, passed down as a plain prop. */ + t: ComposerBarProps['t'] +} + +export function ContextMeter({ useProjection, t }: ContextMeterProps) { + const pressure = useProjection('contextPressure') + const breakdown = useProjection('contextBreakdown') + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + // Outside click / Escape close, one document listener while open (Menu's pattern). + useEffect(() => { + if (!open) return + const onPointerDown = (e: PointerEvent): void => { + if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return + setOpen(false) + } + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + const context = contextOccupancy(pressure) + if (context === null) return null + const percent = context.percent + + // The bar's overall length stays the provider-exact percent; the heuristic + // breakdown only proportions its colored segments. + const breakdownTotal = breakdown === undefined + ? 0 + : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens + const segments = breakdown === undefined || breakdownTotal === 0 + ? null + : ROWS.map(row => ({ key: row.key, color: row.color, share: breakdown[row.key] / breakdownTotal })) + + return ( + + + + + {open && ( +
+
+ {`${percent}%`} + {t('context.used')} + + {`~${formatTokens(context.pressureTokens)} / ${formatTokens(context.contextWindow)}`} + +
+
+ {segments === null + ?
+ : segments.map(segment => ( +
+ ))} +
+ {breakdown !== undefined && ( +
+ {ROWS.map(row => ( +
+
+ + {t(row.label)} +
+
{`~${formatTokens(breakdown[row.key])}`}
+
+ ))} +
+ )} +
+ )} + + ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5f09e7315a..131f63c49d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -19,6 +19,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' +import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' @@ -512,6 +513,7 @@ export function InputBar({
{rightItems} {renderSlot('conversation.input.model', { locked })} + {/* {machineBusy && } */}