From 876065a97d66916b2f336edabd7e9c1b77c9b3bd Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 02:50:47 +0800 Subject: [PATCH 001/190] feat(todo): allow several in_progress todos at once Remove the single-in_progress cap from todo_write execute validation and the durable-log invariant so a task list can mirror genuinely parallel work (concurrent subagents, background commands). Update the tool description to instruct marking every actively worked task in_progress, refresh the tool catalog and keyless snapshot expected outputs, and record the decision in a new Agent Note superseding the original cap. --- .../2026-06-29-todo-write-tool.i18n.yaml | 4 +-- .../feature/2026-06-29-todo-write-tool.md | 4 +-- .../feature/2026-06-29-todo-write-tool.zh.md | 4 +-- ...-07-26-todo-parallel-in-progress.i18n.yaml | 6 ++++ .../2026-07-26-todo-parallel-in-progress.md | 32 +++++++++++++++++++ ...2026-07-26-todo-parallel-in-progress.zh.md | 32 +++++++++++++++++++ docs/core-data-structures/session.i18n.yaml | 4 +-- docs/core-data-structures/session.md | 2 +- docs/core-data-structures/session.zh.md | 2 +- docs/tool-catalog.md | 2 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../both-mode-turn/system-prompt.expected.md | 2 +- .../both-mode-turn/tool-schemas.expected.json | 2 +- .../code-mode-turn/system-prompt.expected.md | 2 +- .../system-prompt.expected.md | 2 +- .../escalation-approved/session.jsonl | 4 +-- .../tool-schemas.expected.json | 2 +- .../escalation-rejected/session.jsonl | 4 +-- .../tests/snapshots/fs-edit/session.jsonl | 2 +- .../fs-escalation-approved/session.jsonl | 6 ++-- .../snapshots/fs-policy-reject/session.jsonl | 4 +-- .../fs-write-overwrite/session.jsonl | 2 +- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 +-- .../lsp-definition/tool-schemas.expected.json | 2 +- .../pty-tools/tool-schemas.expected.json | 2 +- .../session-query-spill/session.jsonl | 2 +- .../tool-schemas.expected.json | 2 +- .../skill-load/tool-schemas.expected.json | 2 +- .../text-turn/tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- .../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/core/session/src/types.ts | 2 +- packages/todo/tool-todo/README.md | 4 +-- packages/todo/tool-todo/src/index.ts | 27 +++++++--------- packages/todo/tool-todo/src/invariant.ts | 3 -- .../todo/tool-todo/tests/invariant.spec.ts | 4 +-- .../todo/tool-todo/tests/tool-todo.spec.ts | 19 ++++++++++- scripts/translation-pairing.manifest.json | 1 + 44 files changed, 150 insertions(+), 68 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md create mode 100644 .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index d7babf16b3..1adbdb75bb 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-29-todo-write-tool.md: df1bee2801b0e01b290b63f6edbe2e5b1be80cb7 -2026-06-29-todo-write-tool.zh.md: 7fa5cb2aad2b32ef0662df04ff6576be14a3a8e7 +2026-06-29-todo-write-tool.md: be5618148d1b4d9f292f27418df1d2576c737d73 +2026-06-29-todo-write-tool.zh.md: 20a8df6030819baeba19380d3b6b75b32c9d239b diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md index df1bee2801..be5618148d 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md @@ -6,7 +6,7 @@ English | [中文](2026-06-29-todo-write-tool.zh.md) ## Problem -The harness gives the model bash and subagent tools but no way to record a structured task list. A todo list serves two co-equal purposes: it steers the model to plan multi-step work and keep the active task unambiguous (at most one active, exactly one while work remains), and it gives an interactive host a live progress checklist. Every reference coding agent surveyed (claude-code, opencode, codex, oh-my-pi, pi) ships some form of this; the harness had nothing. +The harness gives the model bash and subagent tools but no way to record a structured task list. A todo list serves two co-equal purposes: it steers the model to plan multi-step work and keep the active work unambiguous, and it gives an interactive host a live progress checklist. Every reference coding agent surveyed (claude-code, opencode, codex, oh-my-pi, pi) ships some form of this; the harness had nothing. ## Decision @@ -34,7 +34,7 @@ Each list belongs to the calling agent session, and non-agent calls are rejected ### Validation: the cheap middle -The schema enforces type/required/enum. Beyond that, `execute` rejects empty or duplicate `content` and more than one `in_progress` task. claude-code leaves single-in-progress to the prompt; oh-my-pi enforces it in code. We take the middle: enforce the cheap invariants that make a plan *coherent* (no blank tasks, no dupes, at most one active), but leave ordering and the discipline of keeping the list current to the model via the tool description. A rejected write returns an `isError` result so the model self-corrects. +The schema enforces type/required/enum. Beyond that, `execute` rejects empty or duplicate `content`: enforce the cheap invariants that make a plan *coherent* (no blank tasks, no dupes), but leave ordering, active-task discipline, and keeping the list current to the model via the tool description. A rejected write returns an `isError` result so the model self-corrects. The original design also capped the list at one `in_progress` task; that cap was removed for parallel work — the [parallel in-progress Agent Note](2026-07-26-todo-parallel-in-progress.md) owns that decision. ## Why no cordis-catalog entry / no `@mode` diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 7fa5cb2aad..20a8df6030 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃任务明确(最多一个活跃,有剩余工作时恰好一个);同时为交互式宿主提供实时进度清单。调研的所有参考编码 agent(智能体)(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能;本 harness 此前没有。 +harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃工作明确;同时为交互式宿主提供实时进度清单。调研的所有参考编码 agent(智能体)(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能;本 harness 此前没有。 ## 决策 @@ -34,7 +34,7 @@ claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2) ### 校验:低成本的中间路线 -schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重复的 `content`,以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 交给提示词约束;oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。 +schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重复的 `content`:强制执行使计划*连贯*的低成本不变式(无空任务、无重复),但将排序、活跃任务纪律和保持列表最新通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。原始设计还将列表限制为最多一个 `in_progress` 任务;该上限已为并行工作移除——[并行 in-progress Agent Note](2026-07-26-todo-parallel-in-progress.md) 拥有该决定。 ## 为何没有 cordis-catalog 条目 / 没有 `@mode` diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml new file mode 100644 index 0000000000..eee401567f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-26-todo-parallel-in-progress.md: 1e7268407755957df216b684625164c54a93596f +2026-07-26-todo-parallel-in-progress.zh.md: b15a5180ccb4caf456c93719b1bb5897b6023898 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md new file mode 100644 index 0000000000..1e72684077 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -0,0 +1,32 @@ +# Agent Note: Allow several `in_progress` todos at once + +Status: implemented + +English | [中文](2026-07-26-todo-parallel-in-progress.zh.md) + +## Problem + +The [original `todo_write` design](2026-06-29-todo-write-tool.md) enforced at most one `in_progress` task per list, both in `execute` and in the durable-log invariant. That invariant assumes sequential work, but the harness runs genuinely parallel work — concurrent subagents through the delegation tool, background bash commands, workflow fan-out — and a list that can name only one active task cannot represent it. The model was forced to either mislabel parallel tasks as `pending` or merge them into one vague item, and the UI progress checklist under-reported what was actually running. + +## Decision + +Remove the single-`in_progress` cap everywhere it was enforced and let any number of tasks be `in_progress`: + +- `execute` in `packages/todo/tool-todo/src/index.ts` no longer counts `in_progress` items; the `at most one task may be in_progress` error is gone from the tool's stable failure set. +- The durable-log invariant in `packages/todo/tool-todo/src/invariant.ts` no longer rejects snapshots with several active items, so previously-persisted logs are unaffected and parallel snapshots replay cleanly. +- The tool description now instructs the model to mark every actively-worked task `in_progress` — several during parallel work, one for sequential work — and to keep at least one while work remains. + +The remaining coded invariants are unchanged: non-empty trimmed unique `content`, valid status enum. This supersedes the "at most one active" clause of the [original design's validation decision](2026-06-29-todo-write-tool.md); the rest of that Agent Note (whole-list replace, log-backed state, single owner) stands. + +## Why guidance, not a parallelism-aware invariant + +A coded invariant can only see the list, not the runtime: whether two `in_progress` items are legitimate depends on whether work is actually running concurrently, which the tool cannot observe. Enforcing a cap was therefore wrong in exactly the cases parallelism made it matter, and any replacement (for example, capping active items at the live subagent count) would couple the tool to runtimes it deliberately knows nothing about. The discipline of matching `in_progress` marks to genuinely concurrent work moves to the tool description, the same place ordering and list freshness already live. + +## Alternatives considered + +- **Keep the cap and add an explicit parallel opt-in flag** — an extra argument on every call to serve the common case; the flag would be noise for sequential work and still unverifiable. +- **Cap active items at a configured maximum** — any fixed number is arbitrary, and a deployment-varying tunable for list coherence has no principled value. + +## Consequences + +A todo list can now faithfully mirror parallel execution, and UIs render several active markers at once (the TUI's per-status prefix already handles this with no change). The tool no longer rejects a formerly-invalid snapshot shape, so the change is compatible with every previously valid call; only the error path was removed. The model-facing description changed, which re-recorded the tool-catalog page and the assembled snapshot transcripts that pin the schema. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md new file mode 100644 index 0000000000..b15a5180cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 允许同时存在多个 `in_progress` todo + +Status: implemented + +[English](2026-07-26-todo-parallel-in-progress.md) | 中文 + +## 问题 + +[原始 `todo_write` 设计](2026-06-29-todo-write-tool.md)在 `execute` 和持久日志不变式中都强制每个列表至多一个 `in_progress` 任务。该不变式假设工作是顺序进行的,但 harness 会运行真正并行的工作(通过委派工具启动的并发 subagent、后台 bash 命令、工作流扇出),而一个只能标出单个活跃任务的列表无法表示这种情况。模型被迫要么把并行任务错误标记为 `pending`,要么把它们合并成一个含糊的条目,导致 UI 进度清单少报了实际正在运行的工作。 + +## 决策 + +在所有强制它的位置移除单一 `in_progress` 上限,允许任意数量的任务处于 `in_progress`: + +- `packages/todo/tool-todo/src/index.ts` 中的 `execute` 不再统计 `in_progress` 条目;`at most one task may be in_progress` 错误已从工具稳定的失败集合中移除。 +- `packages/todo/tool-todo/src/invariant.ts` 中的持久日志不变式不再拒绝含多个活跃条目的快照,因此此前持久化的日志不受影响,并行快照也能干净回放。 +- 工具描述现在指示模型把每个正在处理的任务标记为 `in_progress`(并行工作时可以有多个,顺序工作时只有一个),并在仍有工作未完成时至少保留一个。 + +其余编码的不变式保持不变:`content` 去除首尾空白后非空且唯一、status 为合法枚举值。本决定取代[原始设计的校验决策](2026-06-29-todo-write-tool.md)中「至多一个活跃」的条款;该 Agent Note 的其余部分(整列表替换、日志支撑的状态、单一所有者)依然成立。 + +## 为何用指引而非感知并行的不变式 + +编码的不变式只能看到列表,看不到运行时:两个 `in_progress` 条目是否合理,取决于工作是否真的在并发运行,而这一点工具无法观测。因此,恰恰在并行让上限变得重要的场景里,强制上限反而是错的;任何替代方案(例如把活跃条目数限制为在线 subagent 的数量)都会把工具耦合到它有意一无所知的运行时上。把 `in_progress` 标记与真正并发的工作对应起来这一纪律,转移到工具描述中,也就是排序与列表新鲜度已经所在的地方。 + +## 曾考虑的替代方案 + +- **保留上限并增加一个显式的并行 opt-in 标志**——为服务常见场景而给每次调用增加一个额外参数;这个标志对顺序工作而言只是噪声,而且仍然无法验证。 +- **把活跃条目限制在一个可配置的上限内**——任何固定数字都是任意的,而为列表连贯性设一个随部署变化的可调参数没有原则性价值。 + +## 后果 + +现在 todo 列表可以忠实反映并行执行,UI 也能一次渲染多个活跃标记(TUI 按状态区分的前缀无需改动即可处理这种情况)。工具不再拒绝一种此前无效的快照形状,因此该改动兼容此前所有合法的调用;被移除的只是错误路径。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及固定 schema 的组装后快照 transcript(文本记录)。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 454bd17c38..28928086cc 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -session.md: d789ffcabb5cb0c744e265b61e322831c1d8a04f -session.zh.md: f4f102861db7403520e9f38cb56613e430718cbe +session.md: 1a627bb316f9b348c6d30d1fb7ed9d575cc73c7d +session.zh.md: 4acfb5fafc46bed45132a890bfcc456131707dd5 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d789ffcabb..1a627bb316 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -159,7 +159,7 @@ The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal interface TodoItem { /** What this task is — a short imperative line shown in the UI. */ content: string - /** Lifecycle state. `in_progress` marks the single task being worked now. */ + /** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */ status: 'pending' | 'in_progress' | 'completed' } ``` diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index f4f102861d..4acfb5fafc 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -159,7 +159,7 @@ interface OutOfBandSessionEventMap {} interface TodoItem { /** What this task is — a short imperative line shown in the UI. */ content: string - /** Lifecycle state. `in_progress` marks the single task being worked now. */ + /** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */ status: 'pending' | 'in_progress' | 'completed' } ``` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 3cdc822a7a..e012b1ca0d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1121,7 +1121,7 @@ The kind-agnostic background-task control surface: background bash commands, PTY ### `todo_write` -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). +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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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). ```json { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index fde52770d5..6a09645d04 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -154,7 +154,7 @@ interface ToolArgsMap { /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; } & Record; - /** 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). */ + /** 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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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). */ todo_write: { /** The COMPLETE task list, replacing any previous list. */ todos: ({ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 73b9176478..fedde409c5 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -352,7 +352,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 833ed36355..ffdd2efb21 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5747fa727e10/57c2f8c3fbf2-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5ae511253182/14a1592c89b1-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 3817b0bc8a..53d1d9650a 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -137,7 +137,7 @@ interface ToolArgsMap { /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; } & Record; - /** 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). */ + /** 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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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). */ todo_write: { /** The COMPLETE task list, replacing any previous list. */ todos: ({ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 0fc8107917..7263238b9a 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -295,7 +295,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 3817b0bc8a..53d1d9650a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -137,7 +137,7 @@ interface ToolArgsMap { /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; } & Record; - /** 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). */ + /** 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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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). */ todo_write: { /** The COMPLETE task list, replacing any previous list. */ todos: ({ diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 3817b0bc8a..53d1d9650a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -137,7 +137,7 @@ interface ToolArgsMap { /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ timeout_ms?: number; } & Record; - /** 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). */ + /** 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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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). */ todo_write: { /** The COMPLETE task list, replacing any previous list. */ todos: ({ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index bf8440bf80..4a2d6b74fe 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -129,8 +129,8 @@ {"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1784821261753,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1784821261754,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"bc159170-7ce0-4162-a6c4-ed41d4ca582f","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"bc159170-7ce0-4162-a6c4-ed41d4ca582f","outcome":"allowed-once"}} +{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"73c68faf-6f91-4a5e-8900-bcc9e0639c99","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"73c68faf-6f91-4a5e-8900-bcc9e0639c99","outcome":"allowed-once"}} {"type":"tool/result","seq":132,"time":1784821261775,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":133,"time":1784821261781,"data":{"turn":1,"step":1}} {"type":"step/start","seq":134,"time":1784821261782,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json index d4973bfea4..ebda45b522 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json @@ -279,7 +279,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 9ae1899961..8823129cc1 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -153,8 +153,8 @@ {"type":"assistant/chunk","seq":151,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":152,"time":1784821263293,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151],"surfaceOp":"append"} {"type":"tool/call","seq":153,"time":1784821263294,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"ad9d426a-bcd7-42df-8ad4-9f4ae8eb160c","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":155,"time":1784821263301,"data":{"id":"ad9d426a-bcd7-42df-8ad4-9f4ae8eb160c","outcome":"rejected"}} +{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"04b36fb6-7b13-47c1-aac7-88fa9b41cf1a","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":155,"time":1784821263301,"data":{"id":"04b36fb6-7b13-47c1-aac7-88fa9b41cf1a","outcome":"rejected"}} {"type":"tool/result","seq":156,"time":1784821263302,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[153],"surfaceOp":"append"} {"type":"step/end","seq":157,"time":1784821263307,"data":{"turn":1,"step":1}} {"type":"step/start","seq":158,"time":1784821263307,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index bcdd4c7b99..d0bd2d5bef 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -130,7 +130,7 @@ {"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} {"type":"tool/call","seq":130,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /private/tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} +{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} {"type":"step/end","seq":132,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":133,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":134,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 180ef6c704..ff45543793 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -87,9 +87,9 @@ {"type":"assistant/chunk","seq":85,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":86,"time":1784821264893,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} {"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"9e3fc97b-19e4-44a1-8ff1-795683948bcd","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"9e3fc97b-19e4-44a1-8ff1-795683948bcd","outcome":"allowed-once"}} -{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} +{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"0f94e54a-24b2-419c-b172-2a2dc540b181","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"0f94e54a-24b2-419c-b172-2a2dc540b181","outcome":"allowed-once"}} +{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":93,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 25c57ee964..8f97feff04 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -78,7 +78,7 @@ {"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} {"type":"tool/call","seq":78,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} {"type":"step/end","seq":80,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":81,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":82,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -226,7 +226,7 @@ {"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224],"surfaceOp":"append"} {"type":"tool/call","seq":226,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} +{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} {"type":"step/end","seq":228,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":229,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":230,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index f3db3493b5..5a3fc5696b 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -115,7 +115,7 @@ {"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} {"type":"tool/call","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} +{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} {"type":"step/end","seq":117,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":118,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":119,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 46de5fa221..dbaaf8a8b6 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -63,7 +63,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"} {"type":"tool/call","seq":63,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 3b86a1c456..4572e99953 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e5dc594b-3ffa-4390-848c-e10b81550c68","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e5dc594b-3ffa-4390-848c-e10b81550c68","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"617a533a-9713-4cb6-9504-b1122a81a1a1","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"617a533a-9713-4cb6-9504-b1122a81a1a1","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 5d27e93da3..f854a899a5 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -316,7 +316,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index e9f7a2ea63..453d752735 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -408,7 +408,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index 26fd2bf5c1..4d172ea6ef 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36006 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1785003308724,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36098 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-15e9b7aaf00a/443130a0742b-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index dde0ba0d7a..311b7c9952 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -483,7 +483,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index d4973bfea4..ebda45b522 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -279,7 +279,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index d4973bfea4..ebda45b522 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -279,7 +279,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index d4973bfea4..ebda45b522 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -279,7 +279,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { 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 045b9bb736..2b14f8e26d 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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":true,"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":1783957884564,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","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 8193973bee..eaffc2b35b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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":true,"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":1783957884701,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","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 7f913ae905..f8162421de 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: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"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","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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":true,"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","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"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() {} }\"}"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"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 99eaf6e4ee..fa90ff7bdf 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","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":"deepseek","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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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":true,"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":0,"data":{"header":{"config":{"provider":"deepseek","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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"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\"}"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index a01f1a6a79..b8d8e30ac2 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -146,7 +146,7 @@ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap] export interface TodoItem { /** What this task is — a short imperative line shown in the UI. */ content: string - /** Lifecycle state. `in_progress` marks the single task being worked now. */ + /** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */ status: 'pending' | 'in_progress' | 'completed' } diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 330f816027..79397436eb 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -14,7 +14,7 @@ The list belongs to the ONE agent session that called the tool. There is no suba ## Validation -Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content` and more than one `in_progress` task (a coherent plan has at most one task active). Ordering and the discipline of keeping the list current are left to the model via the tool description. +Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`. Any number of tasks may be `in_progress` at once — parallel work (concurrent subagents, background commands) legitimately runs several tasks simultaneously. Ordering and the discipline of keeping the list current are left to the model via the tool description. ## Rendering @@ -44,7 +44,7 @@ Prefix-stable while the definition and visibility are unchanged. Plugin lifecycl #### What the model sees -Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: pending, in progress, completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content ""`, `Error: invalid todos: at most one task may be in_progress, got `, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message. +Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: pending, in progress, completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content ""`, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message. #### Token effect diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 1da9914ac9..d420afcecd 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -19,22 +19,24 @@ const 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).' + + 'todo per concrete step before you start. Mark every todo being actively worked ' + + 'on `in_progress` — several at once when work genuinely runs in parallel (e.g. ' + + 'concurrent subagents or background commands), one for sequential work; while ' + + 'work remains, at least one 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).' /** * Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link - * TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry - * has already enforced the status enum; the cast below records that guarantee. + * TodoItem}[]: trimmed non-empty unique content. Any number of items may be in_progress — + * parallel work (subagents, background commands) legitimately runs several tasks at once. The + * registry has already enforced the status enum; the cast below records that guarantee. */ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { const todos: TodoItem[] = [] const seen = new Set() - let inProgress = 0 for (const item of raw) { const content = item.content.trim() if (content.length === 0) { @@ -44,12 +46,7 @@ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { throw new Error(`invalid todos: duplicate content ${JSON.stringify(content)}`) } seen.add(content) - const status = item.status as TodoItem['status'] - if (status === 'in_progress') inProgress++ - todos.push({ content, status }) - } - if (inProgress > 1) { - throw new Error(`invalid todos: at most one task may be in_progress, got ${inProgress}`) + todos.push({ content, status: item.status as TodoItem['status'] }) } return todos } diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index d353c80f77..0fef0b1cce 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -16,7 +16,6 @@ export const inject = ['invariants'] function validateTodos(value: unknown, fail: InvariantFailure): void { if (!Array.isArray(value)) fail('todo/write todos must be an array') const seen = new Set() - let active = 0 for (const item of value) { if (typeof item !== 'object' || item === null) fail('todo/write entries must be objects') const { content, status } = item as Record @@ -28,9 +27,7 @@ function validateTodos(value: unknown, fail: InvariantFailure): void { if (typeof status !== 'string' || !TODO_STATUSES.has(status)) { fail(`todo/write carries unknown status ${JSON.stringify(status)}`) } - if (status === 'in_progress') active += 1 } - if (active > 1) fail(`todo/write contains ${active} in-progress entries; at most one is allowed`) } /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index abfcd74b29..1ec32d9966 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -17,11 +17,12 @@ function event(todos: unknown): SessionEvent { } describe('todo snapshot invariants', () => { - it('accepts a unique whole-list snapshot with one active item', async () => { + it('accepts a unique whole-list snapshot, including several active items', async () => { const ctx = await setup() expect(() => { ctx.emit('session/event', {} as Session, event([ { content: 'Inspect state', status: 'completed' }, { content: 'Apply fix', status: 'in_progress' }, + { content: 'Watch background build', status: 'in_progress' }, { content: 'Run checks', status: 'pending' }, ])) }).not.toThrow() }) @@ -36,7 +37,6 @@ describe('todo snapshot invariants', () => { [[{ content: 'same', status: 'pending' }, { content: 'same', status: 'completed' }], /repeats content/], [[{ content: 'task', status: 42 }], /unknown status/], [[{ content: 'task', status: 'paused' }], /unknown status/], - [[{ content: 'one', status: 'in_progress' }, { content: 'two', status: 'in_progress' }], /at most one/], ])('rejects an incoherent durable todo snapshot', async (todos, message) => { const ctx = await setup() expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).toThrow(message) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 79758cb3ea..0219e3a20f 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -122,10 +122,27 @@ describe('dsh-tool-todo', () => { expect(result.isError).toBe(true) }) + it('accepts several in_progress items at once (parallel work)', async () => { + const ctx = await setup() + const agent = agentWithSession('parallel') + const todos: TodoItem[] = [ + { content: 'run subagent a', status: 'in_progress' }, + { content: 'run subagent b', status: 'in_progress' }, + { content: 'merge results', status: 'pending' }, + ] + const result = await callTodo(ctx, { todos }, { agent }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected todo_write success') + expect(result.value).toEqual({ + todos, + counts: { pending: 1, inProgress: 2, completed: 0 }, + }) + expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) + }) + it.each([ { label: 'empty content', todos: [{ content: ' ', status: 'pending' }], fragment: 'non-empty' }, { label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' }, - { label: 'two in_progress', todos: [{ content: 'a', status: 'in_progress' }, { content: 'b', status: 'in_progress' }], fragment: 'in_progress' }, ])('rejects $label as an isError result', async ({ todos, fragment }) => { const ctx = await setup() const result = await callTodo(ctx, { todos }) diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 4e08844dc9..bd7c1b3335 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -70,6 +70,7 @@ ".agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md", ".agents/notes/implemented/feature/2026-07-10-session-query-service.md", ".agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md", + ".agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md", ".agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md", ".agents/notes/implemented/process/2026-06-11-quality-gates.md", ".agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md", From cb5cc7f15c9dde80c93517e2bb38fb68b1e45f5e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 03:36:51 +0800 Subject: [PATCH 002/190] test(todo): align real-model e2e prompt and scenario with parallel todos The key-gated todo_write e2e still pinned the previous contract: its TODO_SYSTEM_PROMPT instructed at most one in_progress task and the scenario never exercised parallel active tasks. Update the prompt to the new guidance and make the scenario record two simultaneously in_progress tasks; verified against the real API. --- examples/headless-agent/tests/harness.ts | 5 +++-- examples/headless-agent/tests/todo-write.e2e.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index e1edc1dadd..45eb3f8ee2 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -28,8 +28,9 @@ export const SYSTEM_PROMPT = 'You are a coding agent. Use bash for file operatio /** System prompt for the todo_write e2e: nudges the model to plan with the tool. */ export const TODO_SYSTEM_PROMPT = 'You are a coding agent. For multi-step work, ' + 'use the todo_write tool to track a task list: send the WHOLE list each call, ' - + 'keep at most one task in_progress (exactly one while work remains), and mark ' - + 'a task completed as soon as it is done.' + + 'mark every task being actively worked on in_progress (several at once when ' + + 'work runs in parallel, at least one while work remains), and mark a task ' + + 'completed as soon as it is done.' /** Options for {@link codingHarness}. */ export interface CodingHarnessOptions { diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index c3053572d9..83571b1305 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -29,9 +29,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.followup([{ type: 'text', text: - 'Use the todo_write tool to record a plan of exactly two steps: first ' - + '"inspect the failing test" (in_progress), then "apply the fix" (pending). ' - + 'Send both in one todo_write call, then reply with the single word DONE.' }]) + 'Use the todo_write tool to record a plan of exactly three steps for work ' + + 'running in parallel: "inspect the failing test" (in_progress), ' + + '"watch the background build" (in_progress), then "apply the fix" (pending). ' + + 'Send all three in one todo_write call, then reply with the single word DONE.' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -40,13 +41,15 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a const calls = events.filter(event => event.type === 'tool/call') expect(calls.some(event => event.data.name === 'todo_write')).toBe(true) - // And the tool wrote a todo/write event to the log — verify the WORLD. + // And the tool wrote a todo/write event to the log — verify the WORLD, + // including two simultaneously in_progress tasks (the parallel contract). const todoEvents = events.filter(event => event.type === 'todo/write') expect(todoEvents.length).toBeGreaterThan(0) const todos = (todoEvents.at(-1)!).data.todos expect(todos).toEqual([ { content: 'inspect the failing test', status: 'in_progress' }, + { content: 'watch the background build', status: 'in_progress' }, { content: 'apply the fix', status: 'pending' }, ]) }, 120_000) From 2f68deaf84136dc5ec546ce09d18ecc9743860e2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 05:18:48 +0800 Subject: [PATCH 003/190] docs(todo): update invariant catalog for parallel active items The package-invariant catalog Note still described dsh-tool-todo as enforcing at most one active item; align the bilingual pair with the implemented invariant set (unique trimmed items, closed statuses) and re-record the pairing hashes. --- .../2026-07-19-package-invariant-runtime-contracts.i18n.yaml | 4 ++-- .../2026-07-19-package-invariant-runtime-contracts.md | 2 +- .../2026-07-19-package-invariant-runtime-contracts.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 0379a79e52..118a86b600 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: 40d152b2320ac65f9ea7d8732b1a667236d2780a -2026-07-19-package-invariant-runtime-contracts.zh.md: bd2f440d5dce15b352e7bcea0d1243400d290f11 +2026-07-19-package-invariant-runtime-contracts.md: 86d86f69b606c348e85d1ae654b6b35c4326985a +2026-07-19-package-invariant-runtime-contracts.zh.md: 238fa778c6300ea9d4d31c5eb0d47c923c21f7cb diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index 40d152b232..86d86f69b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -50,7 +50,7 @@ The current 103-package workspace has 21 executable companions and 82 justified | `dsh-user-approval` | Approval asked/decided records pair by call and use valid outcomes and policies. | | `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. | | `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. | -| `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | +| `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items and closed statuses. | | `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn, next pre-step position, and elapsed baseline; rendered time parses and does not postdate its event. | Session-backed companions validate existing durable events when they load, using the prefix preceding each candidate where the relationship depends on event order. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index bd2f440d5d..238fa778c6 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -50,7 +50,7 @@ Status: implemented | `dsh-user-approval` | approval asked/decided 记录按 call 配对,并使用有效 outcome 和 policy。 | | `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | | `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | -| `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | +| `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目和封闭 status。 | | `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 | 基于 session 的 companion 在加载时验证已有持久化事件;关系依赖事件顺序时,会使用每个候选事件之前的事件前缀。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 From c2b1afc75d60535bfa39a57ae2018d281dcb03ba Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 05:54:12 +0800 Subject: [PATCH 004/190] test(todo): exercise parallel in_progress items in the keyless snapshot The recorded ACP todo-write scenario still submitted one active item, so no keyless assembled check exercised the new parallel contract. Re-record the scenario with a three-step plan carrying two simultaneously in_progress tasks; the replayed todo/write event now pins the parallel shape without a key. --- .../tests/snapshots/todo-write/input.json | 2 +- .../tests/snapshots/todo-write/session.jsonl | 274 +++++++++--------- 2 files changed, 140 insertions(+), 136 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/todo-write/input.json b/examples/acp-agent/tests/snapshots/todo-write/input.json index 6cc82bdcae..f53711516a 100644 --- a/examples/acp-agent/tests/snapshots/todo-write/input.json +++ b/examples/acp-agent/tests/snapshots/todo-write/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop." } + { "op": "prompt", "text": "Use the todo_write tool to record a plan with exactly three todos for work running in parallel: \"read the code\" (in_progress), \"watch the background build\" (in_progress), \"write the fix\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop." } ] } diff --git a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl index 3f8af53dcd..c311a6aaad 100644 --- a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl @@ -1,135 +1,139 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783352057655,"data":{"title":"Use the todo_write tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783352057657,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783352057657,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":7,"time":1783352058426,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":8,"time":1783352058466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":9,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":10,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":11,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":12,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":13,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" todo"}}} -{"type":"assistant/chunk","seq":14,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_write"}}} -{"type":"assistant/chunk","seq":15,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":16,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":17,"time":1783352058485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" record"}}} -{"type":"assistant/chunk","seq":18,"time":1783352058511,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":19,"time":1783352058512,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plan"}}} -{"type":"assistant/chunk","seq":20,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":21,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":22,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" three"}}} -{"type":"assistant/chunk","seq":23,"time":1783352058514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" todos"}}} -{"type":"assistant/chunk","seq":24,"time":1783352058540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":25,"time":1783352058540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":26,"time":1783352058571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} -{"type":"assistant/chunk","seq":27,"time":1783352058572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" status"}}} -{"type":"assistant/chunk","seq":28,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} -{"type":"assistant/chunk","seq":29,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":30,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":31,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":32,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":33,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":34,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":35,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":36,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":37,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":39,"time":1783352058746,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":40,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"t"}}} -{"type":"assistant/chunk","seq":42,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"odos"}}} -{"type":"assistant/chunk","seq":43,"time":1783352058775,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1783352058775,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":45,"time":1783352058776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"["}}} -{"type":"assistant/chunk","seq":46,"time":1783352058805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"{\""}}} -{"type":"assistant/chunk","seq":47,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":48,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":49,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":50,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"read"}}} -{"type":"assistant/chunk","seq":51,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":52,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" code"}}} -{"type":"assistant/chunk","seq":53,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} -{"type":"assistant/chunk","seq":54,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":55,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} -{"type":"assistant/chunk","seq":56,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":57,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":58,"time":1783352058862,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"in"}}} -{"type":"assistant/chunk","seq":59,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"_pro"}}} -{"type":"assistant/chunk","seq":60,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"gress"}}} -{"type":"assistant/chunk","seq":61,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\"},"}}} -{"type":"assistant/chunk","seq":62,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" {\""}}} -{"type":"assistant/chunk","seq":63,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":64,"time":1783352058889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":65,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":66,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"write"}}} -{"type":"assistant/chunk","seq":67,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":68,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" fix"}}} -{"type":"assistant/chunk","seq":69,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} -{"type":"assistant/chunk","seq":70,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":71,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} -{"type":"assistant/chunk","seq":72,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":73,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":74,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"pending"}}} -{"type":"assistant/chunk","seq":75,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\"},"}}} -{"type":"assistant/chunk","seq":76,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" {\""}}} -{"type":"assistant/chunk","seq":77,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":78,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":79,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":80,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"run"}}} -{"type":"assistant/chunk","seq":81,"time":1783352058948,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":82,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" tests"}}} -{"type":"assistant/chunk","seq":83,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} -{"type":"assistant/chunk","seq":84,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":85,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} -{"type":"assistant/chunk","seq":86,"time":1783352058977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":87,"time":1783352058977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":88,"time":1783352059004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"pending"}}} -{"type":"assistant/chunk","seq":89,"time":1783352059005,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":90,"time":1783352059005,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"}]"}}} -{"type":"assistant/chunk","seq":91,"time":1783352059033,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":92,"time":1783352059095,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":93,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}} -{"type":"assistant/chunk","seq":94,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":95,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":96,"time":1783352059099,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95],"surfaceOp":"append"} -{"type":"tool/call","seq":97,"time":1783352059099,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}} -{"type":"todo/write","seq":98,"time":1783352059100,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}} -{"type":"tool/result","seq":99,"time":1783352059101,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[97],"surfaceOp":"append"} -{"type":"step/end","seq":100,"time":1783352059101,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":101,"time":1783352059102,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":102,"time":1783352059732,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":103,"time":1783352059733,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":104,"time":1783352059835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" todos"}}} -{"type":"assistant/chunk","seq":105,"time":1783352059863,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} -{"type":"assistant/chunk","seq":106,"time":1783352059863,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} -{"type":"assistant/chunk","seq":107,"time":1783352059864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" written"}}} -{"type":"assistant/chunk","seq":108,"time":1783352059864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":109,"time":1783352059892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":110,"time":1783352059892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":111,"time":1783352059893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":112,"time":1783352059893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":113,"time":1783352059920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":114,"time":1783352059920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":115,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":116,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":117,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":118,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":119,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":120,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":121,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":122,"time":1783352059951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":123,"time":1783352059951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":124,"time":1783352059979,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":125,"time":1783352059979,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":126,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":127,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."}}}} -{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":130,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":131,"time":1783352059981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"step/end","seq":132,"time":1783352059981,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":133,"time":1783352059981,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"3f78454d-3fa3-438c-b788-968a644a42e5","createdAt":1785016210496,"cwd":"/tmp/acp-snap-cwd-hVg513","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785016210501,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785016210502,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos for work running in parallel: \"read the code\" (in_progress), \"watch the background build\" (in_progress), \"write the fix\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785016210504,"data":{"title":"Use the todo_write tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785016210513,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785016210514,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785016210929,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785016210929,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785016211030,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785016211065,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785016211066,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785016211066,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785016211067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":12,"time":1785016211067,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" todo"}}} +{"type":"assistant/chunk","seq":13,"time":1785016211100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_write"}}} +{"type":"assistant/chunk","seq":14,"time":1785016211101,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":15,"time":1785016211101,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" record"}}} +{"type":"assistant/chunk","seq":16,"time":1785016211101,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":17,"time":1785016211101,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" three"}}} +{"type":"assistant/chunk","seq":18,"time":1785016211136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" todos"}}} +{"type":"assistant/chunk","seq":19,"time":1785016211137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":20,"time":1785016211137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":21,"time":1785016211175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":22,"time":1785016211176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" status"}}} +{"type":"assistant/chunk","seq":23,"time":1785016211176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} +{"type":"assistant/chunk","seq":24,"time":1785016211208,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":25,"time":1785016211245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":26,"time":1785016211245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":27,"time":1785016211281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":28,"time":1785016211281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":29,"time":1785016211281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":30,"time":1785016211281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":31,"time":1785016211281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":32,"time":1785016211282,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":33,"time":1785016211315,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":34,"time":1785016211316,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":35,"time":1785016211316,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":36,"time":1785016211316,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":37,"time":1785016211422,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":38,"time":1785016211422,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":39,"time":1785016211458,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":40,"time":1785016211459,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1785016211459,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"t"}}} +{"type":"assistant/chunk","seq":42,"time":1785016211459,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"odos"}}} +{"type":"assistant/chunk","seq":43,"time":1785016211494,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1785016211494,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1785016211494,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"["}}} +{"type":"assistant/chunk","seq":46,"time":1785016211541,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":47,"time":1785016211541,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":48,"time":1785016211541,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":49,"time":1785016211542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":50,"time":1785016211542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"read"}}} +{"type":"assistant/chunk","seq":51,"time":1785016211542,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":52,"time":1785016211564,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" code"}}} +{"type":"assistant/chunk","seq":53,"time":1785016211564,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":54,"time":1785016211565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":55,"time":1785016211565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":56,"time":1785016211565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":57,"time":1785016211565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":58,"time":1785016211601,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"in"}}} +{"type":"assistant/chunk","seq":59,"time":1785016211601,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"_pro"}}} +{"type":"assistant/chunk","seq":60,"time":1785016211601,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"gress"}}} +{"type":"assistant/chunk","seq":61,"time":1785016211601,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\"},"}}} +{"type":"assistant/chunk","seq":62,"time":1785016211602,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" {\""}}} +{"type":"assistant/chunk","seq":63,"time":1785016211602,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":64,"time":1785016211635,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":65,"time":1785016211635,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":66,"time":1785016211635,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"watch"}}} +{"type":"assistant/chunk","seq":67,"time":1785016211636,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":68,"time":1785016211636,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" background"}}} +{"type":"assistant/chunk","seq":69,"time":1785016211636,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" build"}}} +{"type":"assistant/chunk","seq":70,"time":1785016211673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":71,"time":1785016211673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":72,"time":1785016211673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":73,"time":1785016211673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":74,"time":1785016211673,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":75,"time":1785016211674,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"in"}}} +{"type":"assistant/chunk","seq":76,"time":1785016211706,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"_pro"}}} +{"type":"assistant/chunk","seq":77,"time":1785016211706,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"gress"}}} +{"type":"assistant/chunk","seq":78,"time":1785016211706,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\"},"}}} +{"type":"assistant/chunk","seq":79,"time":1785016211707,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" {\""}}} +{"type":"assistant/chunk","seq":80,"time":1785016211707,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":81,"time":1785016211707,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":82,"time":1785016211741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":83,"time":1785016211741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"write"}}} +{"type":"assistant/chunk","seq":84,"time":1785016211741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":85,"time":1785016211741,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" fix"}}} +{"type":"assistant/chunk","seq":86,"time":1785016211742,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":87,"time":1785016211742,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":88,"time":1785016211777,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":89,"time":1785016211777,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":90,"time":1785016211777,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":91,"time":1785016211777,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"pending"}}} +{"type":"assistant/chunk","seq":92,"time":1785016211778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":93,"time":1785016211778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"}]"}}} +{"type":"assistant/chunk","seq":94,"time":1785016211812,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":95,"time":1785016211888,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use todo_write to record exactly three todos with the specified statuses, then reply with \"DONE\". Let me do that."}}}} +{"type":"assistant/chunk","seq":96,"time":1785016211888,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"watch the background build\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}]}"}}}} +{"type":"assistant/chunk","seq":97,"time":1785016211888,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5339,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":98,"time":1785016211888,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":99,"time":1785016211892,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use todo_write to record exactly three todos with the specified statuses, then reply with \"DONE\". Let me do that."},{"type":"tool-call","id":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"watch the background build\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":5339,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98],"surfaceOp":"append"} +{"type":"tool/call","seq":100,"time":1785016211893,"data":{"turn":1,"step":1,"callId":"call_00_eeIcoZ0OqxXIa05VhM475887","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"watch the background build\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}]}"}} +{"type":"todo/write","seq":101,"time":1785016211898,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"watch the background build","status":"in_progress"},{"content":"write the fix","status":"pending"}]}} +{"type":"tool/result","seq":102,"time":1785016211900,"data":{"turn":1,"step":1,"callId":"call_00_eeIcoZ0OqxXIa05VhM475887","content":[{"type":"text","text":"Updated todo list: 1 pending, 2 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[100],"surfaceOp":"append"} +{"type":"step/end","seq":103,"time":1785016211905,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":104,"time":1785016211905,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":105,"time":1785016212595,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":106,"time":1785016212595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":107,"time":1785016212738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" todos"}}} +{"type":"assistant/chunk","seq":108,"time":1785016212773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" were"}}} +{"type":"assistant/chunk","seq":109,"time":1785016212773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" written"}}} +{"type":"assistant/chunk","seq":110,"time":1785016212808,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":111,"time":1785016212809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":112,"time":1785016212884,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":113,"time":1785016212887,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":114,"time":1785016212887,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":115,"time":1785016212887,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":116,"time":1785016212887,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":117,"time":1785016212915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":118,"time":1785016212915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":119,"time":1785016212915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":120,"time":1785016212916,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":121,"time":1785016212916,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":122,"time":1785016212951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":123,"time":1785016212951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":124,"time":1785016212951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":125,"time":1785016212951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":126,"time":1785016212952,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":127,"time":1785016212952,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":128,"time":1785016212988,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":129,"time":1785016212988,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":130,"time":1785016212989,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":131,"time":1785016212989,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The todos were written successfully as specified. Now I just need to reply with the single word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":132,"time":1785016212989,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":133,"time":1785016212990,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":234,"outputTokens":25,"cacheReadTokens":5248,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":134,"time":1785016212990,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":135,"time":1785016212990,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos were written successfully as specified. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":234,"outputTokens":25,"cacheReadTokens":5248,"reasoningTokens":22}},"sourceEventSeqs":[105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134],"surfaceOp":"append"} +{"type":"step/end","seq":136,"time":1785016212993,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":137,"time":1785016212993,"data":{"turn":1,"reason":{"kind":"completed"}}} From 0427799cdb91b6a8b246e2b554ffce7e832c18cf Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 26 Jul 2026 16:37:09 +0800 Subject: [PATCH 005/190] docs(todo): sync Chinese README pair with parallel in_progress change Master made bilingual pairing universal and added the tool-todo README pair; bring the Chinese side in line with this branch's validation and stable-failure edits and re-record the pairing hashes. --- packages/todo/tool-todo/README.i18n.yaml | 4 ++-- packages/todo/tool-todo/README.zh.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index a941c774cf..eb9f4c835d 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: db6bbf6970c73767c8e9df1148f98d23047d19b7 -README.zh.md: 6a9b817cb5af2c43b8e66100333e46665359eba8 +README.md: 2f9f35be9c1a0f0234e2c69650ade48e2e5ab199 +README.zh.md: bc94547c693d7a979ede62ea83afb89cc07ccf39 diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 6a9b817cb5..bc94547c69 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -16,7 +16,7 @@ ## 验证 -除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`,以及同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务)。顺序与保持列表最新的纪律由模型根据工具描述负责。 +除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`。任意数量的任务可以同时处于 `in_progress`——并行工作(并发 subagent、后台命令)确实会同时推进多个任务。顺序与保持列表最新的纪律由模型根据工具描述负责。 ## 渲染 @@ -46,7 +46,7 @@ #### 模型所见内容 -每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""`、`Error: invalid todos: at most one task may be in_progress, got ` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 +每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 #### Token 影响 From 59d6fdd28eca9bc998953cb68083d3d5ece02dfc Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 11:36:44 +0800 Subject: [PATCH 006/190] test(snapshots): refresh all three advanced-toolchain headless fixtures The merge of origin/master at 9f218ce9d took master's re-recorded parent session.jsonl wholesale, which reverted this branch's todo_write description in that one file while the two child logs kept the new parallel-in_progress text. The headless snapshot scrubs request headers before comparison, so the three logs disagreed on the model-visible tool contract without any test failing. Re-record the scenario with test:snapshot:refresh, which replays the committed scripts and rewrites all three persisted-log fixtures from the live run. The parent regains the parallel-in_progress todo_write description; both children pick up master's current run_code description and its required `description` parameter, which they were stale on. Fixture content only; no source or contract change, so the owning Agent Note stands as written. --- .../tests/snapshots/advanced-toolchain/session.1.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.2.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.jsonl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 2b14f8e26d..7a23604d0f 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":1783957884564,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","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 eaffc2b35b..1dc996281a 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":1783957884701,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","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 9d2b188a45..ddceb02d59 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: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"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","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 /tmp/advanced-headless.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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":true,"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","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 /tmp/advanced-headless.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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":true,"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":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"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() {} }\"}"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} From f8b0bd31d3264402b62f8f895ff12a56f094d3cf Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 14:25:02 +0800 Subject: [PATCH 007/190] fix(gui): the collapsed plan hint accounts for parallel active items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifting the single-in_progress cap makes a list shape reachable that the web surfaces never received. Two sites derived their one-line summary with todos.find(t => t.status === 'in_progress') — the collapsed TodoPanel header and the todo_write row — which was total under the old cap and silently dropped every active item but the first once several could match: a plan with three running tasks collapsed to the name of one. The expanded list was always correct, so neither PR's tests covered it. Both sites now take planSummary in contract/todo-plan-model.ts, the domain-shared face both the skeleton and toolviews domains may import; the duplicated derivation was why one find could be fixed while the other stayed wrong. The hint names the first active item and suffixes + for the rest, so the collapsed line reports how many tasks are running. The web fixture's todo sample now runs two items in_progress, so the assembled web transcript replays a parallel plan: the row reads '1/4 已完成 · 实现 fixture 样本 +1' over the built bundles. --- ...-07-26-todo-parallel-in-progress.i18n.yaml | 6 +- .../2026-07-26-todo-parallel-in-progress.md | 8 ++- ...2026-07-26-todo-parallel-in-progress.zh.md | 8 ++- apps/web/tests/todo-display.snapshot.ts | 17 +++-- .../client/connection/src/client/fixture.ts | 7 +- .../client/connection/tests/fixture.spec.ts | 4 ++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/contract/todo-plan-model.ts | 48 +++++++++++++ .../src/client/skeleton/TodoPanel.tsx | 10 +-- .../src/client/toolviews/todo-row.tsx | 18 ++--- .../ui-conversation/tests/todo-panel.spec.tsx | 67 +++++++++++++++++-- 13 files changed, 167 insertions(+), 38 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/contract/todo-plan-model.ts diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index eee401567f..efa0dabcb6 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.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-26-todo-parallel-in-progress.md: 1e7268407755957df216b684625164c54a93596f -2026-07-26-todo-parallel-in-progress.zh.md: b15a5180ccb4caf456c93719b1bb5897b6023898 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +2026-07-26-todo-parallel-in-progress.md: eb8d78e2fe2895d952a355226ac518b9ccd40f98 +2026-07-26-todo-parallel-in-progress.zh.md: e610165f0170d92535a3709cc23dab8c77d2767f diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 1e72684077..eb8d78e2fe 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -27,6 +27,12 @@ A coded invariant can only see the list, not the runtime: whether two `in_progre - **Keep the cap and add an explicit parallel opt-in flag** — an extra argument on every call to serve the common case; the flag would be noise for sequential work and still unverifiable. - **Cap active items at a configured maximum** — any fixed number is arbitrary, and a deployment-varying tunable for list coherence has no principled value. +## The display surfaces are part of the change + +Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · ` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. + +Both sites now take `planSummary` in `contract/todo-plan-model.ts`, the domain-shared face the skeleton and toolviews domains may both import. Duplicated derivation was the reason one `find` could be fixed while the other stayed wrong, and the counts were already computed twice. The hint names the first active item and suffixes `+` for the rest, so the collapsed line reports how many tasks are running instead of implying one. Naming every active item was rejected: the hint is a single line next to the composer, and an unbounded join would overflow it — the count degrades predictably where a list does not. + ## Consequences -A todo list can now faithfully mirror parallel execution, and UIs render several active markers at once (the TUI's per-status prefix already handles this with no change). The tool no longer rejects a formerly-invalid snapshot shape, so the change is compatible with every previously valid call; only the error path was removed. The model-facing description changed, which re-recorded the tool-catalog page and the assembled snapshot transcripts that pin the schema. +A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, and the web surfaces needed the shared derivation above. The tool no longer rejects a formerly-invalid snapshot shape, so the change is compatible with every previously valid call; only the error path was removed. The model-facing description changed, which re-recorded the tool-catalog page and the assembled snapshot transcripts that pin the schema. The web fixture's todo sample now runs two items `in_progress`, so the assembled web transcript replays a parallel plan and would fail again if either surface returned to single-active derivation. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index b15a5180cc..e610165f01 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -27,6 +27,12 @@ Status: implemented - **保留上限并增加一个显式的并行 opt-in 标志**——为服务常见场景而给每次调用增加一个额外参数;这个标志对顺序工作而言只是噪声,而且仍然无法验证。 - **把活跃条目限制在一个可配置的上限内**——任何固定数字都是任意的,而为列表连贯性设一个随部署变化的可调参数没有原则性价值。 +## 展示面是本次改动的一部分 + +解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。 + +现在两处都改用 `contract/todo-plan-model.ts` 中的 `planSummary`,即 skeleton 与 toolviews 两个 domain 都可导入的域间共享面。重复的推导正是一处 `find` 被修好而另一处仍然错误的原因,而计数本来就被算了两遍。提示语给出第一个活跃条目,并为其余活跃项追加 `+` 后缀,因此折叠行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:提示语是紧邻输入框的单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。 + ## 后果 -现在 todo 列表可以忠实反映并行执行,UI 也能一次渲染多个活跃标记(TUI 按状态区分的前缀无需改动即可处理这种情况)。工具不再拒绝一种此前无效的快照形状,因此该改动兼容此前所有合法的调用;被移除的只是错误路径。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及固定 schema 的组装后快照 transcript(文本记录)。 +现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,web 各展示面则需要上述共享推导。工具不再拒绝一种此前无效的快照形状,因此该改动兼容此前所有合法的调用;被移除的只是错误路径。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及固定 schema 的组装后快照 transcript(文本记录)。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此组装后的 web transcript 回放的是一个并行计划;若任一展示面退回单活跃项推导,它会再次失败。 diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 3116bf4242..8fef40deee 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -5,7 +5,10 @@ // surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary // derived from the call args) and the TodoPanel plan strip riding the // 'conversation.input.dock' slot (fed by ConversationSnapshot.todos, seeded -// by the tail history page), including the collapse interaction. +// by the tail history page), including the collapse interaction. The sample +// plan runs two items in_progress at once, so both surfaces are pinned against +// a parallel plan — the collapsed one-line hint must account for the second +// active item instead of naming the first and dropping it. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -143,7 +146,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn })), }).toMatchInlineSnapshot(` { - "panelHeader": "Plan1/3", + "panelHeader": "Plan1/4", "panelItems": [ { "status": "completed", @@ -153,12 +156,16 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn "status": "in_progress", "text": "●实现 fixture 样本", }, + { + "status": "in_progress", + "text": "●跑后台构建", + }, { "status": "pending", "text": "○浏览器验收", }, ], - "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本", + "row": "☰更新任务清单1/4 已完成 · 实现 fixture 样本 +1", "rowState": "ok", } `) @@ -179,11 +186,11 @@ it('collapses the plan strip to the in-progress hint and restores it', async () listGone: panel.querySelector('ul') === null, }).toMatchInlineSnapshot(` { - "collapsedHeader": "Plan1/3实现 fixture 样本", + "collapsedHeader": "Plan1/4实现 fixture 样本 +1", "listGone": true, } `) fireEvent.click(header) - expect(panel.querySelectorAll('li')).toHaveLength(3) + expect(panel.querySelectorAll('li')).toHaveLength(4) }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 76a5ba568d..71b271bb84 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -168,14 +168,17 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the - // todo/write snapshot event feeding the TodoPanel plan strip. + // todo/write snapshot event feeding the TodoPanel plan strip. Two items are + // in_progress: the tool permits several, so both surfaces must render a + // parallel plan rather than the first active item alone. const fixtureTodos = [ { content: '梳理需求', status: 'completed' }, { content: '实现 fixture 样本', status: 'in_progress' }, + { content: '跑后台构建', status: 'in_progress' }, { content: '浏览器验收', status: 'pending' }, ] const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ae427710ae..78c13ae3f9 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -84,6 +84,10 @@ describe('createFixtureApi', () => { const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time) expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0) expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0) + // The sample is a parallel plan: the tool permits several in_progress, so + // the surfaces fed from here are exercised against more than one active item. + const snapshot = events[todoAt] as { data: { todos: { status: string }[] } } + expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2) }) it('create adds a session and pushes host/session-added to open host streams', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2ffa02d313..878a0859a7 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: b242812411d513931ecd2767622f9e23fb0aaa34 -README.zh.md: 77f68e02d8d9161c413ae7d224121bc53547ba12 +README.md: 5b12242ac3f477233bd7e897261a9a0c2478aa41 +README.zh.md: 6076e706b2e6e80775149ebcf7c55ab478b41f18 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b242812411..5b12242ac3 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,11 +12,11 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the in-progress item. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the same active hint. Several items may be `in_progress` at once (the tool permits parallel work), so both one-line surfaces derive that hint through `contract/todo-plan-model.ts` `planSummary`: the first active item's content plus `+` for the remaining active ones, and no hint at all when nothing is active or the first active content is unusable. The expanded list needs no such rule — it renders every item with its own status glyph. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. -`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). +`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`, `todo-plan-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). ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 77f68e02d8..6076e706b2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,11 +12,11 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带进行中条目的单行表头。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <活跃提示>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带同一活跃提示的单行表头。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此两处单行面都通过 `contract/todo-plan-model.ts` 的 `planSummary` 推导该提示:第一个活跃条目的内容,加上代表其余活跃项的 `+`;若无活跃项,或第一个活跃项的内容不可用,则完全不给提示。展开态的列表无需此规则——它按条目各自的状态字形渲染每一个条目。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 -`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/*` 子路径获取它们)。 +`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`、`todo-plan-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts b/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts new file mode 100644 index 0000000000..6147810460 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts @@ -0,0 +1,48 @@ +/** + * Pure plan derivation shared by the two todo surfaces: the plan strip header + * (skeleton domain) and the todo_write row (toolviews domain). Both need the + * same done/total counts and the same one-line active hint, and several items + * may be `in_progress` at once — parallel work runs concurrent tasks, so a + * hint built from one active item would silently drop the rest. + * @module + */ + +/** + * One list item as either surface sees it: the typed `TodoItem` off the session + * snapshot, or unvalidated model JSON parsed from a call's args (any field may + * be missing or mistyped). + */ +export interface PlanItemLike { + content?: unknown + status?: unknown +} + +/** Counts plus the one-line hint; `activeHint` is null when there is none to show. */ +export interface PlanSummary { + done: number + total: number + activeHint: string | null +} + +/** + * Derive the counts and the active hint from a whole-list snapshot. The hint is + * the first `in_progress` content suffixed `+` for the remaining active + * items, so a parallel plan reports how many tasks are running rather than + * naming one and hiding the others. It is null when nothing is in progress, or + * when the first active item carries no usable content — model JSON may, and + * the caller then falls back to its own summary. + * @param todos - the whole list, in model order. + * @returns the done/total counts and the active hint. + */ +export function planSummary(todos: readonly PlanItemLike[]): PlanSummary { + const active = todos.filter(t => t.status === 'in_progress') + const first = active[0]?.content + const activeHint = typeof first !== 'string' || first === '' + ? null + : active.length > 1 ? `${first} +${active.length - 1}` : first + return { + done: todos.filter(t => t.status === 'completed').length, + total: todos.length, + activeHint, + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 283eeb3e5e..eec04bc892 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -3,12 +3,15 @@ // no data of its own, hidden while the list is empty. Mounted through the // 'conversation.input.dock' slot (QueueDock posture): the dock adapter does // the selecting, so the panel takes the plain list and stays framework-free. +// Several items may be in_progress at once; the collapsed header's one-line +// hint comes from the shared plan model, which reports the extra active count. import { useState } from 'react' import type { Context } from 'cordis' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { planSummary } from '../contract/todo-plan-model.ts' import css from './TodoPanel.module.css' export interface TodoPanelProps { @@ -25,8 +28,7 @@ export function TodoPanel({ todos }: TodoPanelProps) { const [collapsed, setCollapsed] = useState(false) if (todos.length === 0) return null - const done = todos.filter(t => t.status === 'completed').length - const active = todos.find(t => t.status === 'in_progress') + const { done, activeHint } = planSummary(todos) return (
@@ -38,8 +40,8 @@ export function TodoPanel({ todos }: TodoPanelProps) { > Plan {done}/{todos.length} - {collapsed && active !== undefined && ( - {active.content} + {collapsed && activeHint !== null && ( + {activeHint} )} {collapsed ? : } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index 353e7a5441..a6e0e5af4b 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -1,7 +1,7 @@ // todo_write toolview: plan-flavored summary row replacing the generic // "Tool call" card, registered into the keyed 'conversation.chat.toolview' // hole like the bash sample (a product registration, not a sample). The row -// summarizes the written list (counts + active item) from the call args; the +// summarizes the written list (counts + active items) from the call args; the // durable list itself renders in the TodoPanel above the composer, so the // row stays one line. @@ -10,12 +10,11 @@ import type { Context } from 'cordis' import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' import { toolRowModel } from '../contract/tool-call-model.ts' +import type { PlanItemLike } from '../contract/todo-plan-model.ts' +import { planSummary } from '../contract/todo-plan-model.ts' import css from './todo-row.module.css' -/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ -interface TodoWriteItem { content?: unknown; status?: unknown } - -function isItem(value: unknown): value is TodoWriteItem { +function isItem(value: unknown): value is PlanItemLike { return typeof value === 'object' && value !== null } @@ -32,12 +31,9 @@ function summarize(argsRaw: string): string | null { if (typeof parsed !== 'object' || parsed === null) return null const todos = (parsed as { todos?: unknown }).todos if (!Array.isArray(todos) || !todos.every(isItem)) return null - const done = todos.filter(t => t.status === 'completed').length - const active = todos.find(t => t.status === 'in_progress') - const head = `${done}/${todos.length} 已完成` - return typeof active?.content === 'string' && active.content !== '' - ? `${head} · ${active.content}` - : head + const { done, total, activeHint } = planSummary(todos) + const head = `${done}/${total} 已完成` + return activeHint === null ? head : `${head} · ${activeHint}` } /** One-line plan update row (click opens the raw args in details). Non-ok diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 5bc3aa5c3d..8dc59ec245 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom /** - * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status - * rows, collapse with active hint), its TodoDock adapter (selects the plan off - * the session snapshot and follows changes), and the todo_write toolview row - * (progress summary from args, generic fallback on malformed JSON, error badge, - * keyboard activation). + * Todo display acceptance: the shared plan model (counts + the one-line active + * hint, which carries `+N` once parallel work marks several items in_progress), + * the TodoPanel plan strip (empty-hidden, status rows, collapse with active + * hint), its TodoDock adapter (selects the plan off the session snapshot and + * follows changes), and the todo_write toolview row (progress summary from + * args, generic fallback on malformed JSON, error badge, keyboard activation). */ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -16,6 +17,7 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx' import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx' import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx' +import { planSummary } from '../src/client/contract/todo-plan-model.ts' afterEach(cleanup) @@ -25,6 +27,43 @@ const LIST: TodoItem[] = [ { content: '补测试', status: 'pending' }, ] +/** A parallel plan: three tasks running at once (concurrent subagents). */ +const PARALLEL: TodoItem[] = [ + { content: '搭骨架', status: 'completed' }, + { content: '写组件', status: 'in_progress' }, + { content: '跑后台构建', status: 'in_progress' }, + { content: '读源码', status: 'in_progress' }, + { content: '补测试', status: 'pending' }, +] + +describe('planSummary', () => { + it('counts done/total and names the single active item verbatim', () => { + expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeHint: '写组件' }) + }) + + it('suffixes the extra active count when several items are in progress', () => { + // Parallel work marks several: naming one and hiding the rest would lose them. + expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeHint: '写组件 +2' }) + }) + + it('has no hint when nothing is in progress', () => { + expect(planSummary([{ content: '都完了', status: 'completed' }])) + .toEqual({ done: 1, total: 1, activeHint: null }) + }) + + it('has no hint when the first active item carries no usable content (model JSON)', () => { + // Unvalidated args: a missing, mistyped, or empty content yields no hint, + // even with a second active item that would otherwise supply the count. + expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]).activeHint).toBeNull() + expect(planSummary([{ content: 42, status: 'in_progress' }]).activeHint).toBeNull() + expect(planSummary([{ content: '', status: 'in_progress' }]).activeHint).toBeNull() + }) + + it('is empty-safe', () => { + expect(planSummary([])).toEqual({ done: 0, total: 0, activeHint: null }) + }) +}) + describe('TodoPanel', () => { it('renders nothing while the list is empty', () => { const { container } = render() @@ -52,6 +91,19 @@ describe('TodoPanel', () => { expect(screen.getAllByRole('listitem')).toHaveLength(3) }) + it('shows every parallel active item expanded, and counts the extra ones collapsed', () => { + render() + // Expanded: one row per item, all three active ones carrying the ● glyph. + const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status')) + expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3) + expect(screen.getByText('跑后台构建')).toBeTruthy() + expect(screen.getByText('读源码')).toBeTruthy() + // Collapsed: the hint reports the other two rather than dropping them. + fireEvent.click(screen.getByRole('button', { expanded: true })) + expect(screen.queryByRole('list')).toBeNull() + expect(screen.getByText('写组件 +2')).toBeTruthy() + }) + it('collapsed header omits the hint when nothing is in progress', () => { render() fireEvent.click(screen.getByRole('button', { expanded: true })) @@ -110,6 +162,11 @@ describe('TodoRow', () => { expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy() }) + it('reports the extra active count when the written list runs several tasks', () => { + render() + expect(screen.getByText('1/5 已完成 · 写组件 +2')).toBeTruthy() + }) + it('omits the active clause when no item is in progress and reads running-call args', () => { const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] }) render() From 07f9959d55ec5f147ad2f2735cea67cec2d3c990 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 15:07:43 +0800 Subject: [PATCH 008/190] fix(gui): keep the parallel-active count outside the ellipsized hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both todo one-line surfaces truncate the active hint with overflow: hidden and text-overflow: ellipsis. A "+N" appended to the first active task's name therefore sat at the far end of the truncatable text, so a long task name or a narrow viewport clipped exactly the part that reports the other running tasks, leaving a parallel plan indistinguishable from a sequential one. planSummary now returns activeContent and activeExtra as separate fields instead of one joined activeHint, and each surface renders the count in its own flex: none span beside the ellipsized name: .activeExtra in the collapsed plan strip header, .extra in the todo_write row. Putting the count in front of the name was rejected — the task name is what the reader looks for first. The parallel-plan cases in todo-panel.spec.tsx now assert the count is a separate element from the name, and both fail if the two are rejoined. The assembled web snapshot re-records: the flex gap supplies the visual space, so the transcript reads "实现 fixture 样本+1" with no space in the text nodes. --- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 4 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 4 +- apps/web/tests/todo-display.snapshot.ts | 9 ++-- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/todo-plan-model.ts | 30 +++++++---- .../src/client/skeleton/TodoPanel.module.css | 9 ++++ .../src/client/skeleton/TodoPanel.tsx | 12 +++-- .../src/client/toolviews/todo-row.module.css | 7 +++ .../src/client/toolviews/todo-row.tsx | 31 ++++++++--- .../ui-conversation/tests/todo-panel.spec.tsx | 52 ++++++++++++------- 13 files changed, 116 insertions(+), 54 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index efa0dabcb6..7a6dcd2cce 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: eb8d78e2fe2895d952a355226ac518b9ccd40f98 -2026-07-26-todo-parallel-in-progress.zh.md: e610165f0170d92535a3709cc23dab8c77d2767f +2026-07-26-todo-parallel-in-progress.md: 71123e07f6141346520114bff7029a4dca78ad0c +2026-07-26-todo-parallel-in-progress.zh.md: 5355ea97247115ac0b2290b447d5ffab441e1a4d diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index eb8d78e2fe..71123e07f6 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -31,7 +31,9 @@ A coded invariant can only see the list, not the runtime: whether two `in_progre Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · ` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. -Both sites now take `planSummary` in `contract/todo-plan-model.ts`, the domain-shared face the skeleton and toolviews domains may both import. Duplicated derivation was the reason one `find` could be fixed while the other stayed wrong, and the counts were already computed twice. The hint names the first active item and suffixes `+` for the rest, so the collapsed line reports how many tasks are running instead of implying one. Naming every active item was rejected: the hint is a single line next to the composer, and an unbounded join would overflow it — the count degrades predictably where a list does not. +Both sites now take `planSummary` in `contract/todo-plan-model.ts`, the domain-shared face the skeleton and toolviews domains may both import. Duplicated derivation was the reason one `find` could be fixed while the other stayed wrong, and the counts were already computed twice. The hint names the first active item and counts the rest, so the collapsed line reports how many tasks are running instead of implying one. Naming every active item was rejected: the hint is a single line next to the composer, and an unbounded join would overflow it — the count degrades predictably where a list does not. + +`planSummary` returns the name and the count as separate fields rather than one joined string, because both surfaces truncate the hint with `overflow: hidden` / `text-overflow: ellipsis`. A count appended to the task name sits at the far end of the truncatable text, so exactly the narrow viewports and long task names that make the count informative are the ones that clip it away, leaving a parallel plan indistinguishable from a sequential one. Each surface therefore renders the count in its own `flex: none` span beside the ellipsized name; a shared pre-joined string could not express that split, and pushing the count in front of the name was rejected because the task name is what the reader is looking for first. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index e610165f01..5355ea9724 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -31,7 +31,9 @@ Status: implemented 解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。 -现在两处都改用 `contract/todo-plan-model.ts` 中的 `planSummary`,即 skeleton 与 toolviews 两个 domain 都可导入的域间共享面。重复的推导正是一处 `find` 被修好而另一处仍然错误的原因,而计数本来就被算了两遍。提示语给出第一个活跃条目,并为其余活跃项追加 `+` 后缀,因此折叠行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:提示语是紧邻输入框的单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。 +现在两处都改用 `contract/todo-plan-model.ts` 中的 `planSummary`,即 skeleton 与 toolviews 两个 domain 都可导入的域间共享面。重复的推导正是一处 `find` 被修好而另一处仍然错误的原因,而计数本来就被算了两遍。提示语给出第一个活跃条目,并计数其余活跃项,因此折叠行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:提示语是紧邻输入框的单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。 + +`planSummary` 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为两处面都用 `overflow: hidden` / `text-overflow: ellipsis` 截断该提示。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此两处各自把计数渲染在自己的 `flex: none` span 中,与被省略号截断的任务名并列;共享一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。 ## 后果 diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 8fef40deee..b795d03dc4 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -8,7 +8,10 @@ // by the tail history page), including the collapse interaction. The sample // plan runs two items in_progress at once, so both surfaces are pinned against // a parallel plan — the collapsed one-line hint must account for the second -// active item instead of naming the first and dropping it. +// active item instead of naming the first and dropping it. The `+1` reads +// against the task name with no space because it is a separate non-shrinking +// span (spaced by the flex `gap`), kept outside the ellipsized text so a narrow +// viewport clips the task name rather than the count. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -165,7 +168,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn "text": "○浏览器验收", }, ], - "row": "☰更新任务清单1/4 已完成 · 实现 fixture 样本 +1", + "row": "☰更新任务清单1/4 已完成 · 实现 fixture 样本+1", "rowState": "ok", } `) @@ -186,7 +189,7 @@ it('collapses the plan strip to the in-progress hint and restores it', async () listGone: panel.querySelector('ul') === null, }).toMatchInlineSnapshot(` { - "collapsedHeader": "Plan1/4实现 fixture 样本 +1", + "collapsedHeader": "Plan1/4实现 fixture 样本+1", "listGone": true, } `) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 878a0859a7..844922a67e 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: 5b12242ac3f477233bd7e897261a9a0c2478aa41 -README.zh.md: 6076e706b2e6e80775149ebcf7c55ab478b41f18 +README.md: 4df6712d3beb980f564650ca39caa1e77d8fdb4e +README.zh.md: 7ff3d36867296ee1d870e189bc5103e8cea12731 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5b12242ac3..4df6712d3b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the same active hint. Several items may be `in_progress` at once (the tool permits parallel work), so both one-line surfaces derive that hint through `contract/todo-plan-model.ts` `planSummary`: the first active item's content plus `+` for the remaining active ones, and no hint at all when nothing is active or the first active content is unusable. The expanded list needs no such rule — it renders every item with its own status glyph. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` plus a `+` parallel-active count in its own span, parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the same active hint. Several items may be `in_progress` at once (the tool permits parallel work), so both one-line surfaces derive that hint through `contract/todo-plan-model.ts` `planSummary`: the first active item's content plus a separate count of the remaining active ones, and no hint at all when nothing is active or the first active content is unusable. `planSummary` deliberately does not join the two — both surfaces ellipsize the task name, so a count concatenated onto its end would be the first thing a narrow viewport clips; each renders the count in its own non-shrinking span. The expanded list needs no such rule — it renders every item with its own status glyph. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 6076e706b2..7ff3d36867 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <活跃提示>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带同一活跃提示的单行表头。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此两处单行面都通过 `contract/todo-plan-model.ts` 的 `planSummary` 推导该提示:第一个活跃条目的内容,加上代表其余活跃项的 `+`;若无活跃项,或第一个活跃项的内容不可用,则完全不给提示。展开态的列表无需此规则——它按条目各自的状态字形渲染每一个条目。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <活跃任务>`,并把 `+` 并行活跃计数放在自己的 span 里;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带同一活跃提示的单行表头。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此两处单行面都通过 `contract/todo-plan-model.ts` 的 `planSummary` 推导该提示:第一个活跃条目的内容,加上单独一项「其余活跃项的数量」;若无活跃项,或第一个活跃项的内容不可用,则完全不给提示。`planSummary` 刻意不把两者拼成一个字符串:两处面都会对任务名做省略号截断,把数量接在其末尾时,窄视口最先裁掉的正是这个数量;两处各自把数量渲染在自己的不收缩 span 里。展开态的列表无需此规则——它按条目各自的状态字形渲染每一个条目。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 diff --git a/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts b/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts index 6147810460..a1ac9ae420 100644 --- a/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts +++ b/packages/client/ui-conversation/src/client/contract/todo-plan-model.ts @@ -17,32 +17,40 @@ export interface PlanItemLike { status?: unknown } -/** Counts plus the one-line hint; `activeHint` is null when there is none to show. */ +/** + * Counts plus the two halves of the one-line hint, deliberately NOT pre-joined: + * both surfaces ellipsize the hint, and a count concatenated onto the end of + * the task name is the first thing a narrow viewport clips — exactly when it + * carries information. Each surface renders `activeExtra` in its own + * non-shrinking span beside the truncatable `activeContent`. + */ export interface PlanSummary { done: number total: number - activeHint: string | null + /** First `in_progress` content, or null when there is no usable one to name. */ + activeContent: string | null + /** Active items beyond the first; 0 whenever there is no `activeContent` to sit beside. */ + activeExtra: number } /** - * Derive the counts and the active hint from a whole-list snapshot. The hint is - * the first `in_progress` content suffixed `+` for the remaining active - * items, so a parallel plan reports how many tasks are running rather than - * naming one and hiding the others. It is null when nothing is in progress, or + * Derive the counts and the active hint from a whole-list snapshot. The hint + * names the first `in_progress` item and counts the remaining active ones, so a + * parallel plan reports how many tasks are running rather than naming one and + * hiding the others. `activeContent` is null when nothing is in progress, or * when the first active item carries no usable content — model JSON may, and * the caller then falls back to its own summary. * @param todos - the whole list, in model order. - * @returns the done/total counts and the active hint. + * @returns the done/total counts and the two hint halves. */ export function planSummary(todos: readonly PlanItemLike[]): PlanSummary { const active = todos.filter(t => t.status === 'in_progress') const first = active[0]?.content - const activeHint = typeof first !== 'string' || first === '' - ? null - : active.length > 1 ? `${first} +${active.length - 1}` : first + const named = typeof first === 'string' && first !== '' return { done: todos.filter(t => t.status === 'completed').length, total: todos.length, - activeHint, + activeContent: named ? first : null, + activeExtra: named ? active.length - 1 : 0, } } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 17c9c890a7..531166e1a8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -54,6 +54,15 @@ white-space: nowrap; } +/* The parallel-active count sits outside .activeHint's ellipsis: a count + appended to a long task name would be the first thing clipped. */ +.activeExtra { + flex: none; + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + .chevron { display: grid; flex: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index eec04bc892..9b62568aca 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -4,7 +4,8 @@ // 'conversation.input.dock' slot (QueueDock posture): the dock adapter does // the selecting, so the panel takes the plain list and stays framework-free. // Several items may be in_progress at once; the collapsed header's one-line -// hint comes from the shared plan model, which reports the extra active count. +// hint comes from the shared plan model, which reports the extra active count +// in its own non-shrinking span so ellipsizing the task name cannot clip it. import { useState } from 'react' import type { Context } from 'cordis' @@ -28,7 +29,7 @@ export function TodoPanel({ todos }: TodoPanelProps) { const [collapsed, setCollapsed] = useState(false) if (todos.length === 0) return null - const { done, activeHint } = planSummary(todos) + const { done, activeContent, activeExtra } = planSummary(todos) return (
@@ -40,8 +41,11 @@ export function TodoPanel({ todos }: TodoPanelProps) { > Plan {done}/{todos.length} - {collapsed && activeHint !== null && ( - {activeHint} + {collapsed && activeContent !== null && ( + <> + {activeContent} + {activeExtra > 0 && +{activeExtra}} + )} {collapsed ? : } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index ff4068d49c..f94579c482 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -35,6 +35,13 @@ color: var(--dsw-alias-label-secondary); } +/* Parallel-active count, kept out of .summary's ellipsis so a long task name + clips before the count that reports the other running tasks. */ +.extra { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + .err { flex: none; color: var(--dsw-alias-state-error-primary); diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index a6e0e5af4b..3ffb50032c 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -1,9 +1,10 @@ // todo_write toolview: plan-flavored summary row replacing the generic // "Tool call" card, registered into the keyed 'conversation.chat.toolview' // hole like the bash sample (a product registration, not a sample). The row -// summarizes the written list (counts + active items) from the call args; the -// durable list itself renders in the TodoPanel above the composer, so the -// row stays one line. +// summarizes the written list (counts + active items) from the call args, with +// the parallel-active count in its own non-shrinking span outside the +// ellipsized text; the durable list itself renders in the TodoPanel above the +// composer, so the row stays one line. import type { KeyboardEvent } from 'react' import type { Context } from 'cordis' @@ -18,7 +19,17 @@ function isItem(value: unknown): value is PlanItemLike { return typeof value === 'object' && value !== null } -function summarize(argsRaw: string): string | null { +/** + * The row's summary split at the ellipsis boundary: `text` truncates, `extra` + * is the parallel-active count that must not, so a narrow row never clips the + * one part that says several tasks are running. + */ +interface RowSummary { + text: string + extra: number +} + +function summarize(argsRaw: string): RowSummary | null { let parsed: unknown try { parsed = JSON.parse(argsRaw) @@ -31,9 +42,12 @@ function summarize(argsRaw: string): string | null { if (typeof parsed !== 'object' || parsed === null) return null const todos = (parsed as { todos?: unknown }).todos if (!Array.isArray(todos) || !todos.every(isItem)) return null - const { done, total, activeHint } = planSummary(todos) + const { done, total, activeContent, activeExtra } = planSummary(todos) const head = `${done}/${total} 已完成` - return activeHint === null ? head : `${head} · ${activeHint}` + return { + text: activeContent === null ? head : `${head} · ${activeContent}`, + extra: activeExtra, + } } /** One-line plan update row (click opens the raw args in details). Non-ok @@ -42,7 +56,7 @@ function summarize(argsRaw: string): string | null { export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' - const summary = summarize(argsRaw) ?? model.summary + const summary = summarize(argsRaw) ?? { text: model.summary, extra: 0 } // Button semantics, not a + ))} + {hidden > 0 && {t('produced.more', { count: String(hidden) })}} + + ) +} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 4958894154..83ba5c463c 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -47,6 +47,43 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Files each turn produced, keyed by the assistant seq that closes it — the + * same anchor {@link assistantActionsSeqs} elects, so the row lands under the + * message that reports the work rather than after some mid-turn narration. + * + * The source is the mutation tools' own follow-along `locations`, not the + * closing prose: a produced file must be listed whether or not the model + * remembered to name it. Reads contribute nothing (looking at a file does not + * produce it) and a failed mutation contributes nothing (there is no file to + * open). Paths keep first-seen order and appear once, so a file written and + * then edited in the same turn is one entry. + * @param nodes - snapshot nodes (surface order). + * @returns Per-closing-seq produced paths; a turn that produced none is absent. + */ +export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMap { + const closing = assistantActionsSeqs(nodes) + const byClosingSeq = new Map() + let pending: string[] = [] + const seen = new Set() + for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.isError || node.callView?.card !== 'diff') continue + for (const location of node.callView.locations ?? []) { + if (seen.has(location.path)) continue + seen.add(location.path) + pending.push(location.path) + } + continue + } + if (node.kind !== 'assistant' || !closing.has(node.seq)) continue + if (pending.length > 0) byClosingSeq.set(node.seq, pending) + pending = [] + seen.clear() + } + return byClosingSeq +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 1bda57660d..78114fa2a8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -53,6 +53,9 @@ export const zh = { 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', + 'produced.label': '产物', + 'produced.more': '还有 {count} 个', + 'produced.open': '打开 {name}', 'message.branch': '在新对话中分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', @@ -152,6 +155,9 @@ export const en = { 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', + 'produced.label': 'Produced', + 'produced.more': '{count} more', + 'produced.open': 'Open {name}', 'message.branch': 'Branch into a new conversation', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 110ab6a991..6f855231ac 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, turnDeliverables } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -211,6 +211,61 @@ describe('chat-flow derivation', () => { ]) expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) + + it('turnDeliverables attributes each turn’s written files to the assistant that closes it', () => { + const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: `Write ${paths[0] ?? ''}`, + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, + }) + const produced = turnDeliverables([ + user(1, 'build it'), + assistant(2, 'writing', 1), + wrote(3, 'a', 'out/index.html'), + // Same file touched twice in one turn is one deliverable, in first-seen order. + wrote(4, 'b', 'out/app.css', 'out/index.html'), + // A read is not a deliverable; a failed write has no file to open. + { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, + { ...wrote(6, 'd', 'out/broken.html'), isError: true }, + assistant(7, 'done', 1), + user(8, 'again'), + assistant(9, 'second turn', 2), + ]) + expect(produced.get(7)).toEqual(['out/index.html', 'out/app.css']) + // A turn that produced nothing is absent, not an empty row. + expect(produced.has(9)).toBe(false) + // Nothing at all written: no entries. + expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) + }) + + it('renders the produced files under the closing message and opens one on click', () => { + const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, + }) + // Seven files: six chips plus an explicit remainder — the row bounds what + // it shows and says so rather than dropping the rest silently. + const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] + const h = makeHarness({ + nodes: [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)], + }) + const view = render() + expect(view.getByText('产物')).toBeTruthy() + // Chips carry the basename; the full path stays reachable as the title. + const chip = view.getByRole('button', { name: '打开 deep/a.html' }) + expect(chip.textContent).toBe('a.html') + expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() + expect(view.getByText('还有 1 个')).toBeTruthy() + fireEvent.click(chip) + expect(h.openFile).toHaveBeenCalledWith('deep/a.html') + }) }) describe('ChatView', () => { From f5d53f04b7f02f7ad69c0dd135b61ae4f16a7330 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Fri, 31 Jul 2026 23:20:36 -0700 Subject: [PATCH 030/190] cleanup(web): stop sandboxing served workspace documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preview lost localStorage and cookies under CSP sandbox — measurably, the reported artifact throws SecurityError on load and its theme toggle goes dead. The capability the sandbox denied is one the file's author, an agent already holding this user's shell, never needed the browser for, so the header sat behind a trust boundary it had already crossed. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 ++-- .../2026-07-31-web-workspace-file-links.md | 6 ++--- .../2026-07-31-web-workspace-file-links.zh.md | 6 ++--- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/workspace-files.ts | 22 +++++++------------ .../connection/tests/workspace-files.spec.ts | 13 +++++------ 8 files changed, 26 insertions(+), 33 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 2055af6cea..78f99d03c9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: b7fd5ca240db3ca885e89f4cf6dcc135e7c88de8 -2026-07-31-web-workspace-file-links.zh.md: 74949afe0260d2d9018691740573ff24a1bce820 +2026-07-31-web-workspace-file-links.md: 3cd7a075f091a50a980cde14fdfcd15e810ee1f4 +2026-07-31-web-workspace-file-links.zh.md: 5702730938a76042879989ea961fdbe251830839 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index b7fd5ca240..3cd7a075f0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -20,7 +20,7 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread **The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy. -**Model-authored documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Serving generated markup same-origin with `/api` would put `/api/events.mux` — a readable `GET` stream — one `window.open` away from a page the model wrote. The sandbox costs the preview its `localStorage`, cookies, and same-origin `fetch`; `host.openPath` stays as the full-capability way to open the same file on the Host machine, so the trade is resolved by keeping both affordances rather than by weakening either. +**A served document carries no isolation header.** The first cut sandboxed script-capable documents, reasoning that `/api/events.mux` is a readable same-origin `GET` stream one `window.open` away from a page the model wrote. Measuring it settled the question the other way: under `CSP: sandbox` the report's own artifact throws `SecurityError` on `localStorage` and its theme toggle goes dead, and the capability the sandbox denies is one the page's author — an agent already holding this user's shell — never needed the browser for. A sandbox there sits behind a trust boundary it has already crossed. The rejected middle option (`connect-src 'none'` plus a `Sec-Fetch-Dest: document` refusal on the two SSE `GET`s) restored the preview but was the only option that had to edit the RPC gateway, and its fence goes quiet over plain-HTTP LAN, where `Sec-Fetch-*` is not sent. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. **The client decides by derivation, not by probing.** `IWorkspaces.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns the origin-relative URL, or `undefined` when the path leaves the workspace. `undefined` is exactly the signal to fall back to `openPath`, so a file outside the workspace behaves as it did before and no capability negotiation is needed. @@ -30,9 +30,9 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread - **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule. - **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route. - **`/f/`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope. -- **`connect-src 'none'` instead of `sandbox`, to keep `localStorage` working** — blocks `fetch`/`EventSource` but not `window.open('/api/events.mux')`, which is readable same-origin. The two GET SSE endpoints are what make the sandbox necessary rather than optional. +- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), and rejected anyway: it was the only option adding a rule to the RPC gateway, and the header it depends on is absent over plain-HTTP LAN. More mechanism than the threat it removes. - **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact; consuming that is the reliable source and is left as the follow-up this route unblocks. ## Consequences -Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. `localStorage` is unavailable inside a preview, which is visible on generated pages that persist a theme toggle — the Host opener remains for those. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown. +Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview keeps its own capabilities, so a generated page that persists a theme in `localStorage` works as its author intended. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 74949afe02..5702730938 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -20,7 +20,7 @@ Status: implemented **URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。 -**模型撰写的文档被送进不透明源。** `.html`/`.htm`/`.xhtml`/`.svg` 会带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。若把生成的标记与 `/api` 同源提供,`/api/events.mux`——一条可读的 `GET` 流——离模型写的页面就只有一次 `window.open` 之遥。sandbox 让预览失去 `localStorage`、cookie 与同源 `fetch`;`host.openPath` 作为在 Host 机器上以完整能力打开同一文件的方式保留下来,因此这个取舍是靠同时保留两个交互解决的,而不是靠削弱其中之一。 +**所服务的文档不带任何隔离头。** 最初的做法是给能执行脚本的文档加 sandbox,理由是 `/api/events.mux` 是一条同源可读的 `GET` 流,离模型写的页面只有一次 `window.open` 之遥。实测把这个问题判向了另一边:在 `CSP: sandbox` 之下,报告中那份产物自己就会在 `localStorage` 上抛 `SecurityError`,主题切换当场变死;而 sandbox 所拒绝的那项能力,对这个页面的作者——一个已经握着本用户 shell 的 agent——而言从来就不需要经由浏览器取得。那道 sandbox 立在一条它早已越过的信任边界之后。被否掉的折中方案(`connect-src 'none'` 加上对两个 SSE `GET` 拒绝 `Sec-Fetch-Dest: document`)确实能救回预览,但它是唯一必须去改 RPC 网关的方案,而它依赖的那个头在明文 HTTP 的 LAN 上根本不发送。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。 **客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。 @@ -30,9 +30,9 @@ Status: implemented - **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。 - **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。 - **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。 -- **用 `connect-src 'none'` 代替 `sandbox`,以保住 `localStorage`**——它挡得住 `fetch`/`EventSource`,挡不住 `window.open('/api/events.mux')`,而后者是同源可读的。正是那两个 GET SSE 端点让 sandbox 成为必需而非可选。 +- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——经实测确实可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但仍被否:它是唯一要往 RPC 网关里加规则的方案,而它依赖的那个头在明文 HTTP 的 LAN 上并不发送。机制的分量超过了它移除的威胁。 - **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。 ## 影响 -现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览中无法使用 `localStorage`,这在会持久化主题切换的生成页面上是看得见的——那些场景仍有 Host 打开器。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 +现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览保有自身的能力,因此把主题持久化到 `localStorage` 的生成页面,按其作者的意图正常工作。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 101d8fd61b..452ffe81c8 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 9a08cb4de5531b044bd411ea08595c22f88e5f8a -README.zh.md: cfc427945f42b61288f57f5ca1db9af74dbcfb31 +README.md: ebc2dea2787268e1686eac24565c2433cb5f4b66 +README.zh.md: f5653e9e0e0aed3cbf7a9e6124668942f4dde44a diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 9a08cb4de5..ebc2dea278 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -12,7 +12,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques The node half also serves one file at a time out of a Session's workspace under `/f//`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file. -Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Model-authored markup is served from the same origin as `/api`, where `/api/events.mux` is a readable `GET` stream, so an opaque origin is what keeps a generated page from reading the session event stream one `window.open` away. The cost is borne by the preview: `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, and `host.openPath` remains the full-capability way to open the same file on the Host machine. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. +A served document carries no isolation header and is same-origin with `/api`. That is a decision, not an omission: the only author of these files is the agent already holding this user's shell and filesystem, so a `Content-Security-Policy: sandbox` would sit behind a trust boundary it has already crossed while costing every preview its `localStorage` and cookies — a generated page that remembers a theme breaks under it. A deployment that serves `dsh web` beyond loopback should treat workspace content as trusted, which is already true of everything else its agent does. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index cfc427945f..f5653e9e0e 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -12,7 +12,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust node 半侧还会在 `/f//` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 -能执行脚本的文档——`.html`、`.htm`、`.xhtml`、`.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。模型撰写的标记与 `/api` 同源提供,而 `/api/events.mux` 是一条可读的 `GET` 流,因此正是不透明源阻止了一个生成页面通过一次 `window.open` 读走会话事件流。代价由预览承担:其中无法使用 `localStorage`、cookie 与同源 `fetch`,而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 +所服务的文档不带任何隔离头,与 `/api` 同源。这是一个决定,不是遗漏:这些文件的唯一作者,正是那个已经握着本用户 shell 与文件系统的 agent,因此 `Content-Security-Policy: sandbox` 只会立在一条它早已越过的信任边界之后,代价却是每个预览都失去 `localStorage` 与 cookie——一个会记住主题的生成页面在它之下就是坏的。把 `dsh web` 服务到回环之外的部署,应当把工作区内容按可信处理,而这一点对其 agent 所做的其他一切本来就已成立。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 ## 无密钥 fixture diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index 9ad1b830a3..354b7b2225 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -9,6 +9,14 @@ * names its cwd, and nothing outside that realpath is ever served. The caller * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — * this module is reached only by requests that already passed it. + * + * A served document is same-origin with `/api`, and deliberately carries no + * isolation header. The only author of these files is the agent already + * holding this user's shell and filesystem, so a browser sandbox would not + * move the trust boundary — it would sit behind one already crossed, at the + * cost of `localStorage` and cookies in every preview. Isolating a preview + * becomes a real question when workspace content stops being the viewer's own; + * the answer then is a separate origin, not a header. */ import { createReadStream } from 'node:fs' @@ -51,19 +59,6 @@ const MIME: Record = { const DEFAULT_MIME = 'text/plain; charset=utf-8' -/** Extensions whose top-level navigation can execute script, and so need the sandbox. */ -const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg']) - -/** - * Model-authored documents run in an opaque origin. Without it a generated page - * is same-origin with the RPC gateway, where `/api/events.mux` is a readable - * GET stream — one `window.open` away from every session's events. The cost is - * that `localStorage`, cookies, and same-origin `fetch` are unavailable inside - * a preview; the native-open path (`host.openPath`) remains the full-capability - * way to view a file. - */ -const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms' - /** How the route learns which directory a session may serve from. */ export interface WorkspaceFileDeps { /** @@ -151,7 +146,6 @@ export async function handleWorkspaceFile( // Workspace files change under the agent's hands; a cached preview would // show the previous turn's output after the next edit. 'cache-control': 'no-store', - ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {}, }) if (req.method === 'HEAD') { res.end() diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index fb4288a8b2..eb36fdd6ea 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -58,26 +58,25 @@ function get(path: string, init?: RequestInit): Promise { } describe('workspace file reads', () => { - it('serves a produced document with the sandbox that keeps it off this origin', async () => { + it('serves a produced document with its own capabilities intact', async () => { const response = await get(`${FILES_PATH}/${SESSION}/index.html`) expect(response.status).toBe(200) expect(await response.text()).toBe('

产物

') expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // The whole reason a model-authored page may be served from the RPC - // origin: an opaque origin cannot read /api/events.mux. - expect(response.headers.get('content-security-policy')).toContain('sandbox') + // No isolation header: a preview keeps localStorage and cookies, because + // the file's author already holds this user's shell (see the module doc). + expect(response.headers.get('content-security-policy')).toBeNull() expect(response.headers.get('x-content-type-options')).toBe('nosniff') expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('content-disposition')).toBe('inline') }) - it('sandboxes SVG too, and leaves non-scriptable types alone', async () => { + it('types SVG as a standalone document rather than sniffable bytes', async () => { const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('content-security-policy')).toContain('sandbox') + expect(svg.headers.get('x-content-type-options')).toBe('nosniff') const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') - expect(text.headers.get('content-security-policy')).toBeNull() }) it('shows an unknown extension as text rather than downloading it', async () => { From dcf485ac5c2ee7373b0de683871cc63eb4a83152 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Sat, 1 Aug 2026 01:08:16 -0700 Subject: [PATCH 031/190] fix(web): address the review of the workspace-file route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Isolation is restored on the premise the review corrected: a workspace file need not be agent-authored — a read row makes every file in a cloned repository openable — and a same-origin active document was measured driving /api/settings.describe to a 200 with full data. Script-capable documents go back into an opaque origin; the preview's lost localStorage is the known cost, and a separate serving origin is the way to retire it. - confine(): a workspace rooted at a filesystem root has a realpath already ending in the separator, and the doubled prefix 403'd every child. - turnDeliverables(): reset on the turn boundary, not only at a closing assistant, so an interrupted turn cannot spill into the next turn's row; and recognize a mutation by render intent (diff card, or generic with kind 'edit') so str_replace_editor's insert counts. - 405 answers name the methods it allows. - The e2e now cold-seeds a recorded WRITE turn, so the assembled application covers the Produced row, its chip's served URL, and the isolation header. - Agent Note matched to what shipped (the row is in this PR, not deferred); ui-conversation README documents the new destination and the row; the fixture lane's dead-tab quirk and the cold-path listing cost are recorded. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 11 ++-- .../2026-07-31-web-workspace-file-links.zh.md | 11 ++-- apps/web/tests/workspace-file-open.e2e.ts | 66 ++++++++++--------- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 4 +- packages/client/connection/README.zh.md | 4 +- packages/client/connection/src/index.ts | 3 +- .../client/connection/src/workspace-files.ts | 31 ++++++--- .../client/connection/tests/node-half.spec.ts | 11 +++- .../connection/tests/workspace-files.spec.ts | 27 +++++--- .../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/chat-flow.ts | 62 +++++++++++++---- .../ui-conversation/tests/chat-view.spec.tsx | 32 +++++++++ packages/host/apiproxy/src/api-proxy.ts | 4 ++ 17 files changed, 205 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 78f99d03c9..584f3c899d 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: 3cd7a075f091a50a980cde14fdfcd15e810ee1f4 -2026-07-31-web-workspace-file-links.zh.md: 5702730938a76042879989ea961fdbe251830839 +2026-07-31-web-workspace-file-links.md: a4bd8a2fecc2f1cb29d61a575b1ae0d31e5ebdd3 +2026-07-31-web-workspace-file-links.zh.md: 1d001710b2548343cb811fd6cca282cf956e95d9 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index 3cd7a075f0..a4bd8a2fec 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-31-web-workspace-file-links.zh.md) -> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, and the conversation's file-open affordance switching to it. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration. +> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, the conversation's file-open affordance switching to it, and the produced-files row a finished turn ends with. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration. ## Problem @@ -20,7 +20,7 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread **The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy. -**A served document carries no isolation header.** The first cut sandboxed script-capable documents, reasoning that `/api/events.mux` is a readable same-origin `GET` stream one `window.open` away from a page the model wrote. Measuring it settled the question the other way: under `CSP: sandbox` the report's own artifact throws `SecurityError` on `localStorage` and its theme toggle goes dead, and the capability the sandbox denies is one the page's author — an agent already holding this user's shell — never needed the browser for. A sandbox there sits behind a trust boundary it has already crossed. The rejected middle option (`connect-src 'none'` plus a `Sec-Fetch-Dest: document` refusal on the two SSE `GET`s) restored the preview but was the only option that had to edit the RPC gateway, and its fence goes quiet over plain-HTTP LAN, where `Sec-Fetch-*` is not sent. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. +**Script-capable documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. The decision was briefly taken the other way, on the premise that these files are agent-authored and their author already holds this user's shell, so a browser sandbox would sit behind a trust boundary already crossed. Review falsified the premise: a workspace file need not be agent-authored at all — a read row makes every file in a cloned repository openable — and a same-origin active document was measured driving `/api/settings.describe` to a `200` with full data, so the loopback-pinned settings and credential plane was reachable from a page nobody in this session wrote. The cost is real and stays: a preview has no `localStorage` or cookies, so a generated page that remembers a theme will not. Restoring that without reopening the hole needs a separate origin, which is a different change; `host.openPath` remains the full-capability way to open the same file. **The client decides by derivation, not by probing.** `IWorkspaces.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns the origin-relative URL, or `undefined` when the path leaves the workspace. `undefined` is exactly the signal to fall back to `openPath`, so a file outside the workspace behaves as it did before and no capability negotiation is needed. @@ -30,9 +30,10 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread - **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule. - **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route. - **`/f/`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope. -- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), and rejected anyway: it was the only option adding a rule to the RPC gateway, and the header it depends on is absent over plain-HTTP LAN. More mechanism than the threat it removes. -- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact; consuming that is the reliable source and is left as the follow-up this route unblocks. +- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable against the SSE-read vector (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), but it never addressed the larger one: same-origin `fetch` to a POST method is what reaches the configuration plane, and blocking `connect-src` from the served document is exactly what a hostile document would not do to itself. Only an origin boundary contains it. +- **Serving `/f` from its own loopback port** — the one option that keeps both isolation and preview capabilities, and the shape a separate-origin answer would take. Deferred, not rejected: it needs a second listener with its own lifecycle plus the port plumbed to the client, which is a change of a different size than this one. +- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead; linkifying the prose stays unnecessary rather than merely deferred. ## Consequences -Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview keeps its own capabilities, so a generated page that persists a theme in `localStorage` works as its author intended. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown. +Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview runs without `localStorage` or cookies, visible on generated pages that persist a theme — the Host opener remains for those, and a separate serving origin is the way to retire the limitation. The produced-files row ships here too: `turnDeliverables` reads a turn's output off the mutation tools' render intent (a diff card, or a generic card whose `kind` is `edit`), resets on the turn boundary so an interrupted turn cannot spill into the next, and renders under the closing assistant. Still deferred: linkification inside assistant Markdown, and any cross-session view of past deliverables. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 5702730938..1d001710b2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-31-web-workspace-file-links.md) | 中文 -> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导,以及会话中打开文件的交互改指向它。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 +> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导、会话中打开文件的交互改指向它,以及完成的一轮以其产出文件收尾的那一行。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 ## 问题 @@ -20,7 +20,7 @@ Status: implemented **URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。 -**所服务的文档不带任何隔离头。** 最初的做法是给能执行脚本的文档加 sandbox,理由是 `/api/events.mux` 是一条同源可读的 `GET` 流,离模型写的页面只有一次 `window.open` 之遥。实测把这个问题判向了另一边:在 `CSP: sandbox` 之下,报告中那份产物自己就会在 `localStorage` 上抛 `SecurityError`,主题切换当场变死;而 sandbox 所拒绝的那项能力,对这个页面的作者——一个已经握着本用户 shell 的 agent——而言从来就不需要经由浏览器取得。那道 sandbox 立在一条它早已越过的信任边界之后。被否掉的折中方案(`connect-src 'none'` 加上对两个 SSE `GET` 拒绝 `Sec-Fetch-Dest: document`)确实能救回预览,但它是唯一必须去改 RPC 网关的方案,而它依赖的那个头在明文 HTTP 的 LAN 上根本不发送。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。 +**能执行脚本的文档被送进不透明源。** `.html`/`.htm`/`.xhtml`/`.svg` 带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。这个决定曾短暂地被判向另一边,前提是这些文件由 agent 撰写、而其作者已经握着本用户的 shell,因此浏览器 sandbox 只会立在一条早已越过的信任边界之后。评审推翻了这个前提:工作区文件根本不必由 agent 撰写——一条 read 行就让 clone 下来的仓库里任何文件变得可打开——而同源的活动文档经实测能把 `/api/settings.describe` 打到 `200` 并拿到完整数据,也就是说被钉在回环的设置与凭据面,可以被一个本次会话中无人撰写的页面触达。代价真实且保留:预览没有 `localStorage` 与 cookie,因此会记住主题的生成页面在其中记不住。要在不重新打开这个洞的前提下取回它,需要一个独立的源,那是另一个尺寸的改动;`host.openPath` 仍是以完整能力打开同一文件的方式。 **客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。 @@ -30,9 +30,10 @@ Status: implemented - **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。 - **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。 - **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。 -- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——经实测确实可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但仍被否:它是唯一要往 RPC 网关里加规则的方案,而它依赖的那个头在明文 HTTP 的 LAN 上并不发送。机制的分量超过了它移除的威胁。 -- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。 +- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——针对“读走 SSE 流”这条向量经实测可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但它从未触及更大的那条:真正够到配置面的是向 POST 方法发起的同源 `fetch`,而“从所服务文档一侧封住 `connect-src`”恰恰是敌意文档不会对自己做的事。只有源边界能收住它。 +- **让 `/f` 跑在自己的回环端口上**——唯一能同时保住隔离与预览能力的选项,也是“独立的源”这个答案该有的形状。是暂缓而非否决:它需要一个带自身生命周期的第二监听器,外加把端口铺到客户端,那是另一个尺寸的改动。 +- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它;因此把正文链接化是不必要,而不只是被推迟。 ## 影响 -现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览保有自身的能力,因此把主题持久化到 `localStorage` 的生成页面,按其作者的意图正常工作。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 +现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览在没有 `localStorage` 与 cookie 的情况下运行,这在会持久化主题的生成页面上看得见——那些场景仍有 Host 打开器,而独立的服务源是退休这条限制的路。产出文件行也在本次一并落地:`turnDeliverables` 依据改写工具的渲染意图(diff 卡片,或 `kind` 为 `edit` 的 generic 卡片)读出一轮的产出,在 turn 边界重置以免中断的一轮溢进下一轮,并渲染在收尾 assistant 之下。仍然暂缓:助手 Markdown 内部的链接化,以及任何跨会话回看既往产物的视图。 diff --git a/apps/web/tests/workspace-file-open.e2e.ts b/apps/web/tests/workspace-file-open.e2e.ts index 63d4266cf4..98d239beb1 100644 --- a/apps/web/tests/workspace-file-open.e2e.ts +++ b/apps/web/tests/workspace-file-open.e2e.ts @@ -1,29 +1,31 @@ -// Web e2e scenario: clicking a tool row's file path opens that file in a new -// browser tab, served by the web transport's own /f route. Cold-seeds the -// seeded-history fixture (zero model calls). The surface package tests can -// assert which opener the click reaches, but only the assembled application -// proves the opened URL actually serves the workspace file — the whole point -// of the route (docs/testing.md snapshot rule). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +// Web e2e scenario: a produced file, from the row that lists it to the bytes +// the browser gets. Cold-seeds a recorded write turn (zero model calls). +// Package tests cover the derivation and the route in isolation, but only the +// assembled application shows that the turn's Produced row, the URL it opens, +// and the file on disk are the same thing (docs/testing.md snapshot rule). +import { readFile, writeFile, mkdir } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' -// Borrowed read-only: this scenario needs any settled turn whose tool rows -// carry a workspace file path, not a new recording (message-actions pattern). -const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a +// file, not a new recording (the message-actions borrowing pattern). +const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'workspace-file-open-web-e2e' -const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +/** The file the borrowed recording's write tool produces. */ +const PRODUCED = 'policy-neutral.txt' +/** An active document placed alongside it, for the isolation header the route puts on those. */ +const ACTIVE = 'preview.html' -describe('web e2e: opening a workspace file from a tool row', () => { +describe('web e2e: opening a produced file from the conversation', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -31,15 +33,13 @@ describe('web e2e: opening a workspace file from a tool row', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) - // The seeded Session's cwd is the scaffold workspace itself; the recording's - // own nested directory is written too, so the seed's paths stay resolvable. + // The seeded Session's cwd is the scaffold workspace; the recording's own + // nested directory is created too, so its paths stay resolvable. await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) - for (const dir of [scaffold.workspaceCwd, join(scaffold.workspaceCwd, 'workspace')]) { - await writeFile(join(dir, 'a.txt'), 'alpha\n') - await writeFile(join(dir, 'b.txt'), 'beta\n') - } + await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') + await writeFile(join(scaffold.workspaceCwd, ACTIVE), '

produced

\n') const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -53,7 +53,7 @@ describe('web e2e: opening a workspace file from a tool row', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('opens the read row’s file in a new tab, served from the session workspace', async () => { + it.skipIf(MODE === 'record')('ends the turn with its produced file, which opens as the workspace file itself', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -61,26 +61,32 @@ describe('web e2e: opening a workspace file from a tool row', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() - await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - // The row summary IS the link: a button whose label is the tool's path. - const fileLink = page.getByRole('button', { name: 'a.txt', exact: true }).first() - await fileLink.waitFor({ timeout: 10_000 }) + // The row the turn ends with — derived from the write call's locations, + // not from whatever the closing message happened to say. + const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() + await chip.waitFor({ timeout: 15_000 }) + expect(await chip.innerText()).toBe(PRODUCED) + const [opened] = await Promise.all([ page.context().waitForEvent('page', { timeout: 15_000 }), - fileLink.click(), + chip.click(), ]) await opened.waitForLoadState('domcontentloaded') - expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/a.txt`) - expect(await opened.locator('body').innerText()).toContain('alpha') + expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/${PRODUCED}`) + expect(await opened.locator('body').innerText()).toContain('neutral') - // The served response is a workspace read, not a download, and never cached - // past the turn that produced it. const served = await page.request.get(opened.url()) expect(served.status()).toBe(200) expect(served.headers()['x-content-type-options']).toBe('nosniff') expect(served.headers()['cache-control']).toBe('no-store') + // A workspace file is not necessarily agent-authored, so an active document + // is served into an opaque origin rather than same-origin with /api. + const active = await page.request.get(`${scaffold.baseUrl}/f/${SEED_ID}/${ACTIVE}`) + expect(active.status()).toBe(200) + expect(active.headers()['content-security-policy']).toContain('sandbox') + // Nothing outside the Session's workspace is reachable through the route. const escape = await page.request.get(`${scaffold.baseUrl}/f/${SEED_ID}/..%2Fetc%2Fhosts`) expect(escape.status()).toBe(404) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 452ffe81c8..68e7f42e21 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: ebc2dea2787268e1686eac24565c2433cb5f4b66 -README.zh.md: f5653e9e0e0aed3cbf7a9e6124668942f4dde44a +README.md: cc7070500645f407b46f0326bf74479a81c579d8 +README.zh.md: 600b4f19d3262cb2381ec9368bc92b93db183603 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index ebc2dea278..cc70705006 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -12,10 +12,12 @@ The node half guards every request under `/api` before bridging (`src/api-reques The node half also serves one file at a time out of a Session's workspace under `/f//`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file. -A served document carries no isolation header and is same-origin with `/api`. That is a decision, not an omission: the only author of these files is the agent already holding this user's shell and filesystem, so a `Content-Security-Policy: sandbox` would sit behind a trust boundary it has already crossed while costing every preview its `localStorage` and cookies — a generated page that remembers a theme breaks under it. A deployment that serves `dsh web` beyond loopback should treat workspace content as trusted, which is already true of everything else its agent does. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. +Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`, which runs them in an opaque origin. A workspace file is not necessarily agent-authored: a read row makes every file in a cloned repository openable, so an active document served same-origin with `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. The cost is borne by the preview — `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, so a generated page that remembers a theme will not — and `host.openPath` remains the full-capability way to open the same file on the Host machine. Restoring those capabilities without reopening the hole needs a separate origin, not a weaker header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. ## Keyless fixture +The fixture carrier has no `/f` route, and `IWorkspaces.fileUrl` derives its URL in the browser regardless of carrier, so a file-path row clicked under `fixture=` opens a tab that 404s where the Host opener used to be a silent no-op. Fixture pages carry no file rows today; a fixture scenario that adds them should stub the derivation rather than teach the in-memory carrier to serve bytes. + Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index f5653e9e0e..600b4f19d3 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -12,10 +12,12 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust node 半侧还会在 `/f//` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 -所服务的文档不带任何隔离头,与 `/api` 同源。这是一个决定,不是遗漏:这些文件的唯一作者,正是那个已经握着本用户 shell 与文件系统的 agent,因此 `Content-Security-Policy: sandbox` 只会立在一条它早已越过的信任边界之后,代价却是每个预览都失去 `localStorage` 与 cookie——一个会记住主题的生成页面在它之下就是坏的。把 `dsh web` 服务到回环之外的部署,应当把工作区内容按可信处理,而这一点对其 agent 所做的其他一切本来就已成立。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 +能执行脚本的文档——`.html`、`.htm`、`.xhtml`、`.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`,让它们运行在不透明源中。工作区文件未必由 agent 撰写:一条 read 行就能让 clone 下来的仓库里任何文件变得可打开,因此与 `/api` 同源提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。代价由预览承担——其中无法使用 `localStorage`、cookie 与同源 `fetch`,因此一个会记住主题的生成页面在预览里记不住——而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。要在不重新打开这个洞的前提下取回那些能力,需要的是一个独立的源,而不是一个更弱的头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 ## 无密钥 fixture +fixture 载体没有 `/f` 路由,而 `IWorkspaces.fileUrl` 无论载体为何都在浏览器侧推导 URL,因此在 `fixture=` 下点击文件路径行会打开一个 404 的标签页,而此处从前是 Host 打开器的静默空操作。今天的 fixture 页面并不含文件行;若某个 fixture 场景要加上它们,应当把这段推导打桩,而不是教这个内存载体去提供字节。 + 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。 ## 模型体验 diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 4f9ce7d51b..59bab263ea 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -118,7 +118,8 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { return } if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) + // RFC 9110 §15.5.6: a 405 names the methods the resource does support. + res.writeHead(405, { allow: 'GET, HEAD' }) res.end() return } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index 354b7b2225..e173c33a40 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -10,13 +10,13 @@ * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — * this module is reached only by requests that already passed it. * - * A served document is same-origin with `/api`, and deliberately carries no - * isolation header. The only author of these files is the agent already - * holding this user's shell and filesystem, so a browser sandbox would not - * move the trust boundary — it would sit behind one already crossed, at the - * cost of `localStorage` and cookies in every preview. Isolating a preview - * becomes a real question when workspace content stops being the viewer's own; - * the answer then is a separate origin, not a header. + * Script-capable documents are served into an opaque origin. A workspace file + * is not necessarily agent-authored — a read row makes every file in a cloned + * repository openable — so an active document served same-origin with `/api` + * reaches the whole RPC surface, the loopback-pinned settings and credential + * methods included. The sandbox costs a preview its `localStorage` and + * cookies; restoring those without reopening that hole needs a separate + * origin, not a weaker header. */ import { createReadStream } from 'node:fs' @@ -59,6 +59,17 @@ const MIME: Record = { const DEFAULT_MIME = 'text/plain; charset=utf-8' +/** Extensions whose top-level navigation can execute script, and so need the sandbox. */ +const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg']) + +/** + * The opaque origin an active workspace document runs in. Without it the + * document is same-origin with `/api` and its script passes the browser-trust + * fence, which admits every method — including the ones pinned to loopback + * precisely because they mutate settings and credentials. + */ +const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms' + /** How the route learns which directory a session may serve from. */ export interface WorkspaceFileDeps { /** @@ -85,8 +96,11 @@ function fail(res: ServerResponse, status: number): void { */ async function confine(cwd: string, segments: readonly string[]): Promise { const root = await realpath(cwd) + // A filesystem root already ends in the separator; appending a second one + // would make every child fail the prefix test and 403 the whole workspace. + const prefix = root.endsWith(sep) ? root : root + sep const real = await realpath(resolve(root, ...segments)) - return real.startsWith(root + sep) ? real : undefined + return real.startsWith(prefix) ? real : undefined } /** @@ -146,6 +160,7 @@ export async function handleWorkspaceFile( // Workspace files change under the agent's hands; a cached preview would // show the previous turn's output after the next edit. 'cache-control': 'no-store', + ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {}, }) if (req.method === 'HEAD') { res.end() diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 0b2c58ab37..8ab1fbce8e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -34,11 +34,15 @@ function fakeRequest(headers: Record, url = `${API_PATH}/session } /** Response recorder compatible with both the fence's short-circuit and the bridge. */ -function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { - const state: { status?: number; body?: unknown } = {} +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown; headers?: Record } } { + const state: { status?: number; body?: unknown; headers?: Record } = {} const response = Object.assign(new EventEmitter(), { writableEnded: false, - writeHead(value: number) { state.status = value; return this }, + writeHead(value: number, headers?: Record) { + state.status = value + if (headers !== undefined) state.headers = headers + return this + }, write() { return true }, end(this: { writableEnded: boolean }, value?: unknown) { if (value !== undefined) state.body = value @@ -177,6 +181,7 @@ describe('connection node half: the /f workspace-file route', () => { Object.assign(post, { method: 'POST' }) await filesRoute(routes).handler(post, written.response) expect(written.state.status).toBe(405) + expect(written.state.headers).toMatchObject({ allow: 'GET, HEAD' }) await dispose() }) diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index eb36fdd6ea..6aee38e4d9 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -8,7 +8,7 @@ import type { AddressInfo } from 'node:net' import type { ServerResponse } from 'node:http' import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { Writable } from 'node:stream' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -37,7 +37,8 @@ beforeAll(async () => { const server = createServer((req, res) => { void handleWorkspaceFile(req, res, { - cwdFor: async sessionId => sessionId === SESSION ? workspace : undefined, + // 'rooted' names the filesystem root, the separator-terminated realpath case. + cwdFor: async sessionId => sessionId === SESSION ? workspace : sessionId === 'rooted' ? sep : undefined, }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -58,25 +59,35 @@ function get(path: string, init?: RequestInit): Promise { } describe('workspace file reads', () => { - it('serves a produced document with its own capabilities intact', async () => { + it('serves an active document into an opaque origin', async () => { const response = await get(`${FILES_PATH}/${SESSION}/index.html`) expect(response.status).toBe(200) expect(await response.text()).toBe('

产物

') expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // No isolation header: a preview keeps localStorage and cookies, because - // the file's author already holds this user's shell (see the module doc). - expect(response.headers.get('content-security-policy')).toBeNull() + // A workspace file is not necessarily agent-authored, and same-origin + // script here would pass the browser-trust fence into every RPC method. + expect(response.headers.get('content-security-policy')).toContain('sandbox') + expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin') expect(response.headers.get('x-content-type-options')).toBe('nosniff') expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('content-disposition')).toBe('inline') }) - it('types SVG as a standalone document rather than sniffable bytes', async () => { + it('sandboxes SVG too, and leaves inert types unrestricted', async () => { const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('x-content-type-options')).toBe('nosniff') + expect(svg.headers.get('content-security-policy')).toContain('sandbox') const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(text.headers.get('content-security-policy')).toBeNull() + }) + + it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => { + // `realpath('/')` is '/', so a naive `root + sep` prefix is '//' and every + // child of that workspace would 403. + const rooted = await fetch(`${origin}${FILES_PATH}/rooted${new URL(`file://${workspace}/notes.txt`).pathname}`) + expect(rooted.status).toBe(200) + expect(await rooted.text()).toBe('plain') }) it('shows an unknown extension as text rather than downloading it', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 505d693bb6..2a2857839d 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: b61a70fb079eb6a1bc2a67b682a337ffdf708b79 -README.zh.md: 0bb1740b166cfacc2bc79fe2f49793796f66c365 +README.md: ba55f0704500034b7afb37258064fe0801aaee91 +README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b61a70fb07..ba55f07045 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 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)). -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab, served by the web transport's `/f` route, so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). @@ -42,6 +42,8 @@ The chat stats line takes its token accounting from two generic token-meter proj `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). +A finished turn ends with the files it produced. `chat-flow.ts`'s `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the same seq `assistantActionsSeqs` elects. It shows six chips (basename, full path as the title) plus an explicit remainder count, and each chip opens through the same `openFile` the tool rows use. + ## Model Experience None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 0bb1740b16..4212908b35 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 @@ -42,6 +42,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 `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/*` 子路径获取它们)。 +完成的一轮以它产出的文件收尾。`chat-flow.ts` 的 `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 选出的同一个 seq。它展示六枚 chip(文本为文件名,完整路径作为 title),外加一个显式的剩余计数,每枚 chip 都经由工具行所用的同一个 `openFile` 打开。 + ## 模型体验 无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 83ba5c463c..7b5c53d7ac 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -32,6 +32,21 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } +/** + * Paths a call view reports having created or changed, by render intent rather + * than tool name: a diff card, or a generic card whose kind is `edit` (the + * shape `str_replace_editor`'s insert presents). Every other card produces + * nothing to open — a read looked, a delete removed, a terminal ran. + */ +function producedPaths(view: ToolResultNode['callView']): readonly string[] { + if (view === null) return [] + if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) + if (view.card === 'generic' && view.kind === 'edit') { + return (view.locations ?? []).map(location => location.path) + } + return [] +} + /** * Seq set of assistants that own IconActions: the last content-text assistant * in each turn. Mid-turn narration (text before tools) stays chrome-free. @@ -54,10 +69,19 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon * * The source is the mutation tools' own follow-along `locations`, not the * closing prose: a produced file must be listed whether or not the model - * remembered to name it. Reads contribute nothing (looking at a file does not - * produce it) and a failed mutation contributes nothing (there is no file to - * open). Paths keep first-seen order and appear once, so a file written and - * then edited in the same turn is one entry. + * remembered to name it. A mutation is recognized by render intent, not by + * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape + * `str_replace_editor`'s insert presents) — so a new mutation tool joins by + * declaring what it does. Reads contribute nothing (looking at a file does not + * produce it), and neither do deletes (there is nothing left to open) or + * failed calls. Paths keep first-seen order and appear once, so a file written + * and then edited in the same turn is one entry. + * + * Accumulation resets on the turn boundary, not merely at the closing + * assistant: a turn that mutates files and then ends without content text + * (interrupted mid-tool, or a turn whose last text precedes its last tool + * result) must not spill its paths into the next turn's row, nor leave `seen` + * suppressing a file the next turn legitimately rewrites. * @param nodes - snapshot nodes (surface order). * @returns Per-closing-seq produced paths; a turn that produced none is absent. */ @@ -65,21 +89,37 @@ export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMa const closing = assistantActionsSeqs(nodes) const byClosingSeq = new Map() let pending: string[] = [] - const seen = new Set() + let seen = new Set() + let turn: number | undefined for (const node of nodes) { if (node.kind === 'tool-result') { - if (node.isError || node.callView?.card !== 'diff') continue - for (const location of node.callView.locations ?? []) { - if (seen.has(location.path)) continue - seen.add(location.path) - pending.push(location.path) + if (node.isError) continue + for (const path of producedPaths(node.callView)) { + if (seen.has(path)) continue + seen.add(path) + pending.push(path) } continue } + // Tool results carry no turn of their own, so the boundary is read off the + // nodes that do. A user message opens a turn without reporting a number, + // which is why the tracked turn goes back to undefined there: the next + // node to report one is stating the current turn, not entering a new one. + if (node.kind === 'user') { + turn = undefined + pending = [] + seen = new Set() + } else if ('turn' in node) { + if (turn !== undefined && node.turn !== turn) { + pending = [] + seen = new Set() + } + turn = node.turn + } if (node.kind !== 'assistant' || !closing.has(node.seq)) continue if (pending.length > 0) byClosingSeq.set(node.seq, pending) pending = [] - seen.clear() + seen = new Set() } return byClosingSeq } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 6f855231ac..d95d1db9ff 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -241,6 +241,38 @@ describe('chat-flow derivation', () => { expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) }) + it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => { + const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'str_replace_editor'), + // str_replace_editor's insert mutates behind a generic card, so the + // discriminant is the render intent, not the card shape alone. + callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, + }) + const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], + }, + }) + const produced = turnDeliverables([ + user(1, 'insert a line'), + inserted(2, 'i', 'notes.md'), + assistant(3, 'inserted', 1), + // Turn 2 mutates and then ends with no content text (interrupted, or its + // last text preceded the tool): its paths must not ride into turn 3. + user(4, 'now rewrite it'), + wrote(5, 'w', 'leaked.txt'), + user(6, 'and again'), + wrote(7, 'w2', 'notes.md'), + assistant(8, 'done', 3), + ]) + expect(produced.get(3)).toEqual(['notes.md']) + // Turn 3 lists only its own file — and `seen` did not suppress the rewrite + // of a path an earlier turn already touched. + expect(produced.get(8)).toEqual(['notes.md']) + expect([...produced.values()].flat()).not.toContain('leaked.txt') + }) + it('renders the produced files under the closing message and opens one on click', () => { const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ ...toolResult(seq, callId, 'write'), diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3c20785657..63e1f0007c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2299,6 +2299,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (live !== undefined) return live.session.header.cwd const persistence = ctx.get('sessionPersistence') if (persistence === undefined) return undefined + // TODO(persistence/by-id): a full listing per lookup. Harmless while the + // caller is one preview open, but a served document with N relative + // sub-resources pays it N times; a by-id header read on the persistence + // seam would retire it. return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd }, } From 59bfe77fb821eeadcea4b97cbb50981d04b556bd Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Sat, 1 Aug 2026 02:17:25 -0700 Subject: [PATCH 032/190] feat(web): serve workspace files from their own origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sandbox header bought isolation by taking the document's origin away, and measuring that cost decided against it: the reported artifact throws SecurityError on load, and because an uncaught exception aborts the rest of its ` + const head = html.indexOf('') + if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` + /* v8 ignore next -- headless fixture pages may lack ; prepending keeps read-before-shell ordering. */ + return `${script}${html}` +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 59bab263ea..f0a60bbfb9 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -4,14 +4,13 @@ import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' // The merge-free types subpath: pulling the session package's root into this // client-registered program would merge the host `sessions` service over the // browser runtime's own. import type { SessionId } from '@deepseek-ai/dsh-session/types' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { handleWorkspaceFile } from './workspace-files.ts' +import { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -74,8 +73,10 @@ const PRIVILEGED_METHODS = new Set([ * additionally pass it with an empty trust list, which pins them to loopback. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). + * @returns a promise settling once the workspace-file listener is bound and + * its port published — the page must never render before it can address one. */ -export function apply(ctx: Context, config?: ConnectionConfig): void { +export async function apply(ctx: Context, config?: ConnectionConfig): Promise { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] // Config boundary: a malformed entry fails the load loudly here rather than @@ -108,23 +109,19 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // would merge their host-side Context declarations into the browser lane. const cwdFor = (sessionId: string): Promise => ctx.apiProxy.workspaceRootOf(sessionId as SessionId) - const filesRoute: WebRoute = { - kind: 'prefix', - path: FILES_PATH, - handler: async (req, res) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - res.writeHead(403) - res.end('forbidden') - return - } - if (req.method !== 'GET' && req.method !== 'HEAD') { - // RFC 9110 §15.5.6: a 405 names the methods the resource does support. - res.writeHead(405, { allow: 'GET, HEAD' }) - res.end() - return - } - await handleWorkspaceFile(req, res, { cwdFor }) - }, - } - ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route') + // Workspace files get their own port, and therefore their own origin: an + // active document served beside `/api` would reach every method through the + // fence below. The listen is awaited inside the effect so the port is known + // before the index tap that publishes it can run. + await ctx.effect(async () => { + const files = await listenForWorkspaceFiles( + ctx.httpServer.host, trustedHosts, { cwdFor }, + (error) => { ctx.logger.error(error) }, + ) + const untap = ctx.httpServer.tapIndex(html => injectFilesPort(html, files.port)) + return async () => { + untap() + await files.close() + } + }, 'client-connection: /f listener') } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index e173c33a40..c934b14516 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -10,13 +10,11 @@ * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — * this module is reached only by requests that already passed it. * - * Script-capable documents are served into an opaque origin. A workspace file - * is not necessarily agent-authored — a read row makes every file in a cloned - * repository openable — so an active document served same-origin with `/api` - * reaches the whole RPC surface, the loopback-pinned settings and credential - * methods included. The sandbox costs a preview its `localStorage` and - * cookies; restoring those without reopening that hole needs a separate - * origin, not a weaker header. + * Isolation is the listener's, not this module's: these responses carry no + * sandbox header because they are served from their own port, and therefore + * their own origin ([files-server](./files-server.ts)). A served document + * keeps `localStorage`, cookies, and its own `fetch`, while the API stays + * cross-origin to it. */ import { createReadStream } from 'node:fs' @@ -59,17 +57,6 @@ const MIME: Record = { const DEFAULT_MIME = 'text/plain; charset=utf-8' -/** Extensions whose top-level navigation can execute script, and so need the sandbox. */ -const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg']) - -/** - * The opaque origin an active workspace document runs in. Without it the - * document is same-origin with `/api` and its script passes the browser-trust - * fence, which admits every method — including the ones pinned to loopback - * precisely because they mutate settings and credentials. - */ -const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms' - /** How the route learns which directory a session may serve from. */ export interface WorkspaceFileDeps { /** @@ -160,7 +147,6 @@ export async function handleWorkspaceFile( // Workspace files change under the agent's hands; a cached preview would // show the previous turn's output after the next edit. 'cache-control': 'no-store', - ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {}, }) if (req.method === 'HEAD') { res.end() diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6892dc7721..4b323182bb 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,10 +8,11 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { search: string } } +type Win = { location?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number } afterEach(() => { delete (globalThis as Win).location + delete (globalThis as Win).__DSH_FILES_PORT__ }) async function mount(): Promise { @@ -62,4 +63,28 @@ describe('connection client apply', () => { } expect(seen.some(u => u.includes('/api/'))).toBe(true) }) + + it('addresses a workspace file on the port the host published, and only inside the workspace', async () => { + const win = globalThis as Win + win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' } + win.__DSH_FILES_PORT__ = 4321 + const handle = await mount() + const session = 's-1' as never + // Same hostname the page was reached by — a LAN client must reach previews + // too — and the published port, which is what makes it another origin. + expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')) + .toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html') + // Outside the workspace there is nothing this transport may serve, which + // is the signal a caller falls back to openPath on. + expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined() + }) + + it('serves no file URL on a page no host published a port into', async () => { + const win = globalThis as Win + win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' } + const handle = await mount() + // The keyless fixture lane: no workspace-file origin exists, so the row + // falls back to the Host opener instead of opening a dead tab. + expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined() + }) }) diff --git a/packages/client/connection/tests/files-server.spec.ts b/packages/client/connection/tests/files-server.spec.ts new file mode 100644 index 0000000000..4a2618709b --- /dev/null +++ b/packages/client/connection/tests/files-server.spec.ts @@ -0,0 +1,44 @@ +/** The workspace-file listener's own failure and publication paths. */ +import { describe, expect, it } from 'vitest' +import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' +import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts' + +describe('workspace-file listener', () => { + it('answers 400 and reports the failure when the directory lookup throws', async () => { + const seen: Error[] = [] + const files = await listenForWorkspaceFiles( + '127.0.0.1', [], + { cwdFor: () => Promise.reject(new Error('store unavailable')) }, + (error) => { seen.push(error) }, + ) + try { + // A lookup failure is the host's problem, not a miss: it must not become + // an unhandled rejection, and it must not be reported as "not found". + const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`) + expect(response.status).toBe(400) + expect(seen.map(error => error.message)).toEqual(['store unavailable']) + } finally { + await files.close() + } + }) + + it('closes idempotently and stops answering', async () => { + const files = await listenForWorkspaceFiles( + '127.0.0.1', [], { cwdFor: async () => undefined }, () => {}, + ) + const origin = `http://127.0.0.1:${String(files.port)}` + expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404) + await files.close() + await files.close() + await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow() + }) +}) + +describe('injectFilesPort', () => { + it('publishes the port as the first script in head', () => { + const html = injectFilesPort('x', 4321) + expect(html).toContain('') + // Ahead of anything the shell might read it from. + expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('')) + }) +}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 8ab1fbce8e..2561a0846f 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -15,14 +15,21 @@ import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' import { API_PATH, apply, inject } from '../src/index.ts' /** Structural httpServer fake: the plugin only touches register(). */ -function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> { +function fakeHttpServer( + routes: WebRoute[], + taps: ((html: string) => string)[] = [], +): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> { return { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, - tapIndex: () => () => {}, + tapIndex(transform) { + taps.push(transform) + return () => { taps.splice(taps.indexOf(transform), 1) } + }, port: 0, + host: '127.0.0.1', } } @@ -61,21 +68,39 @@ function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy { async function mounted( config?: { trustedHosts?: string[] }, workspaces: Record<string, string> = {}, -): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> { +): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> { const ctx = new Context() const routes: WebRoute[] = [] - ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + const taps: ((html: string) => string)[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService) ctx.provide('apiProxy', fakeApiProxy(workspaces)) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, dispose: () => fiber.dispose() } + return { routes, taps, dispose: () => fiber.dispose() } } -/** The /f route is registered after /api; both are prefix routes on the same server. */ -function filesRoute(routes: WebRoute[]): WebRoute { - const route = routes.find(candidate => candidate.path === FILES_PATH) - if (route === undefined) throw new Error('the /f route was not registered') - return route +/** One raw GET whose Host header is spoofed (fetch forbids setting it). */ +function statusWithHost(origin: string, path: string, host: string): Promise<number> { + const url = new URL(origin) + return new Promise((resolve, reject) => { + const request = httpRequest( + { host: url.hostname, port: url.port, path, method: 'GET', headers: { host } }, + (response) => { + response.resume() + response.on('end', () => { resolve(response.statusCode ?? 0) }) + }, + ) + request.on('error', reject) + request.end() + }) +} + +/** The workspace-file origin the node half published into the index page. */ +function filesOrigin(taps: ((html: string) => string)[]): string { + const html = taps.reduce((acc, tap) => tap(acc), '<head></head>') + const port = /__DSH_FILES_PORT__ = (\d+)/.exec(html)?.[1] + if (port === undefined) throw new Error(`no workspace-file port was published: ${html}`) + return `http://127.0.0.1:${port}` } describe('connection node half', () => { @@ -89,11 +114,19 @@ describe('connection node half', () => { expect(routes).toHaveLength(0) }) - it('registers both transport prefix routes and removes them with the fiber', async () => { - const { routes, dispose } = await mounted() - expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }]) + it('registers the /api route and publishes a separate workspace-file origin, both removed with the fiber', async () => { + const { routes, taps, dispose } = await mounted() + // The API keeps one prefix on the shared server; workspace files get a + // port of their own, which is the origin boundary between them. + expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }]) + const origin = filesOrigin(taps) + expect(new URL(origin).port).not.toBe('') + expect((await fetch(`${origin}${FILES_PATH}/absent/x.txt`)).status).toBe(404) await dispose() expect(routes).toHaveLength(0) + expect(taps).toHaveLength(0) + // Disposal reaches quiescence: the socket is gone, not merely unrouted. + await expect(fetch(`${origin}${FILES_PATH}/absent/x.txt`)).rejects.toThrow() }) it('refuses an untrusted Host on any /api path before the bridge runs', async () => { @@ -154,7 +187,7 @@ describe('connection node half', () => { }) }) -describe('connection node half: the /f workspace-file route', () => { +describe('connection node half: the workspace-file origin', () => { /** A workspace holding one file, torn down with the returned disposer. */ async function workspace(): Promise<{ cwd: string; remove: () => Promise<void> }> { const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-')) @@ -162,40 +195,35 @@ describe('connection node half: the /f workspace-file route', () => { return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) } } - /** HEAD keeps the assertion on the route's decision, not on the byte stream. */ - function head(url: string, headers: Record<string, string> = { host: '127.0.0.1:3080' }): IncomingMessage { - const request = fakeRequest(headers, url) - Object.assign(request, { method: 'HEAD' }) - return request - } - - it('applies the same browser-trust fence as /api, and refuses writes', async () => { - const { routes, dispose } = await mounted() - const untrusted = fakeResponse() - await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`, { host: 'harness.example' }), untrusted.response) - expect(untrusted.state.status).toBe(403) - expect(untrusted.state.body).toBe('forbidden') - - const written = fakeResponse() - const post = fakeRequest({ host: '127.0.0.1:3080' }, `${FILES_PATH}/s-1/index.html`) - Object.assign(post, { method: 'POST' }) - await filesRoute(routes).handler(post, written.response) - expect(written.state.status).toBe(405) - expect(written.state.headers).toMatchObject({ allow: 'GET, HEAD' }) + it('applies the same browser-trust fence as /api, refuses writes, and serves nothing else', async () => { + const { taps, dispose } = await mounted() + const origin = filesOrigin(taps) + // Rebound Host: refused before any filesystem work, exactly as on /api. + // node's fetch refuses to set Host (a forbidden header), so the spoof goes + // through the raw client — the same parse the server really performs. + expect(await statusWithHost(origin, `${FILES_PATH}/s-1/index.html`, 'harness.example')).toBe(403) + const written = await fetch(`${origin}${FILES_PATH}/s-1/index.html`, { method: 'POST' }) + expect(written.status).toBe(405) + expect(written.headers.get('allow')).toBe('GET, HEAD') + // This origin is one route wide: no index, no SPA fallback, no API. + expect((await fetch(`${origin}/`)).status).toBe(404) + expect((await fetch(`${origin}${API_PATH}/session.list`, { method: 'POST' })).status).toBe(404) await dispose() }) it('confines reads to the directory the gateway names for that session', async () => { const { cwd, remove } = await workspace() - const { routes, dispose } = await mounted(undefined, { 's-1': cwd }) - const served = fakeResponse() - await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`), served.response) - expect(served.state.status).toBe(200) + const { taps, dispose } = await mounted(undefined, { 's-1': cwd }) + const origin = filesOrigin(taps) + const served = await fetch(`${origin}${FILES_PATH}/s-1/index.html`) + expect(served.status).toBe(200) + expect(await served.text()).toBe('<h1>ok</h1>') + // A served document keeps its own capabilities: the port is the boundary, + // so nothing here strips the document of its origin. + expect(served.headers.get('content-security-policy')).toBeNull() // A session the gateway names no directory for has no workspace to confine // against, so there is nothing to serve. - const unknown = fakeResponse() - await filesRoute(routes).handler(head(`${FILES_PATH}/s-absent/index.html`), unknown.response) - expect(unknown.state.status).toBe(404) + expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404) await dispose() await remove() }) diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index 6aee38e4d9..8e33a6751b 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -59,27 +59,25 @@ function get(path: string, init?: RequestInit): Promise<Response> { } describe('workspace file reads', () => { - it('serves an active document into an opaque origin', async () => { + it('serves a produced document with its own capabilities intact', async () => { const response = await get(`${FILES_PATH}/${SESSION}/index.html`) expect(response.status).toBe(200) expect(await response.text()).toBe('<h1>产物</h1>') expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // A workspace file is not necessarily agent-authored, and same-origin - // script here would pass the browser-trust fence into every RPC method. - expect(response.headers.get('content-security-policy')).toContain('sandbox') - expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin') + // No isolation header: the listener's own port is the origin boundary, so + // a preview keeps localStorage and cookies (see files-server). + expect(response.headers.get('content-security-policy')).toBeNull() expect(response.headers.get('x-content-type-options')).toBe('nosniff') expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('content-disposition')).toBe('inline') }) - it('sandboxes SVG too, and leaves inert types unrestricted', async () => { + it('types SVG as a standalone document rather than sniffable bytes', async () => { const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('content-security-policy')).toContain('sandbox') + expect(svg.headers.get('x-content-type-options')).toBe('nosniff') const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') - expect(text.headers.get('content-security-policy')).toBeNull() }) it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => { diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index dbc0f3b30f..3e64ef3717 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -56,17 +56,6 @@ export interface IWorkspaces { * @param path - absolute or host-resolvable path. */ openPath(path: string): Promise<void> - /** - * URL serving one file out of a session's workspace, for a UI that opens a - * produced file in the browser instead of on the Host machine. - * @param sessionId - the session whose cwd anchors the path. - * @param cwd - that session's working directory, or `undefined` when unknown. - * @param path - the path a tool reported (absolute, or relative to `cwd`). - * @returns the origin-relative URL, or `undefined` when the path lies - * outside the workspace — which this transport never serves, leaving - * {@link IWorkspaces.openPath} as the only way to reach it. - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 837a7daa03..c0eb46fcf9 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -5,7 +5,6 @@ import type { DirectoryListing, IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' -import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts' @@ -240,18 +239,6 @@ export class WorkspacesService implements IWorkspaces { } } - /** - * URL serving one file out of a session's workspace. - * @param sessionId - the session whose cwd anchors the path. - * @param cwd - that session's working directory, or `undefined` when unknown. - * @param path - the path a tool reported (absolute, or relative to `cwd`). - * @returns the origin-relative URL, or `undefined` for a path outside the workspace. - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { - const segments = workspaceFileSegments(cwd, path) - if (segments === undefined) return undefined - return workspaceFileUrl(sessionId, segments) - } /** * Rename a Workspace. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d389efe319..a5827173a8 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,6 +26,7 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { api, + fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index fd7858d60c..a35983d890 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,6 +20,7 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, + fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 3d9cef547f..4323d7ffce 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -276,21 +276,6 @@ describe('WorkspacesService', () => { await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/) }) - it('addresses a workspace file by URL, and only inside the workspace', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) - const workspaces = new WorkspacesService(ctx, api, sessions) - const session = 's-1' as SessionId - // The URL is derived, not fetched: no wire call answers a link. - expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html') - expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html') - // Outside the workspace there is nothing this transport may serve, which - // is the signal a caller falls back to openPath on. - expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined() - expect(api.calls).toHaveLength(0) - }) - it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { const ctx = new Context() const api = new FakeApiClient() diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index e892d9cd52..6d7093a148 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -25,6 +25,7 @@ "vitest": "^4.1.8" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-web-react": "^0.0.1", @@ -35,6 +36,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", diff --git a/packages/client/test-runtime/src/connection.ts b/packages/client/test-runtime/src/connection.ts new file mode 100644 index 0000000000..5df5d5a053 --- /dev/null +++ b/packages/client/test-runtime/src/connection.ts @@ -0,0 +1,48 @@ +/** Test-owned connection face: the transport members features read off `ctx.connection`. */ +import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ConnectionHandle, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' + +/** + * Connection test double. Implements the same `ConnectionHandle` face features + * receive as `ctx.connection`, so a production face change breaks this double + * at compile time. The wire client is not modelled — a feature that needs one + * composes its own connection over a fake api client; this double exists for + * the transport facts features read synchronously, above all the + * workspace-file URL. + */ +export class TestConnection implements ConnectionHandle { + /** + * The workspace-file port the host would have published into the page. + * Unset — the default, and the keyless fixture lane's real state — makes + * {@link TestConnection.fileUrl} answer `undefined`, which is the signal a + * caller falls back to the Host opener on. + */ + filesPort: number | undefined + + /** The wire client; unused by this double's consumers and absent by construction. */ + readonly api: IApiClient = undefined as unknown as IApiClient + + /** + * Stream-loop starter (inert). + * @returns a stop handle that does nothing. + */ + start(): { stop(): void } { + return { stop: () => {} } + } + + /** + * Workspace-file URL, deriving exactly as production does so a feature test + * sees the real inside/outside-workspace split. + * @param sessionId - the Session whose cwd anchors the path. + * @param cwd - that Session's working directory. + * @param path - the path a tool reported. + * @returns the absolute URL on the workspace-file origin, or undefined when + * the path leaves the workspace or no port is published. + */ + fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { + if (this.filesPort === undefined) return undefined + const segments = workspaceFileSegments(cwd, path) + if (segments === undefined) return undefined + return `http://localhost:${String(this.filesPort)}${workspaceFileUrl(sessionId, segments)}` + } +} diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 5ef5350434..cdbdca75a2 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -29,11 +29,13 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import { registerDomSnapshotSerializer } from './snapshot.ts' import { TestSessions } from './sessions.ts' +import { TestConnection } from './connection.ts' import { TestWorkspaces } from './workspaces.ts' import type { Stabilizer } from './fixtures.ts' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' +export { TestConnection } from './connection.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' @@ -175,6 +177,8 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces + /** The transport double features read as `ctx.connection`. */ + readonly connection: TestConnection private readonly stabilizer: Stabilizer = async (fn) => { await act(async () => { await fn() }) @@ -195,8 +199,10 @@ export class SlotTestRuntime { this.root = new TestRoot(slots, this.stabilizer) this.sessions = new TestSessions(this.stabilizer, ctx) this.workspaces = new TestWorkspaces(this.stabilizer) + this.connection = new TestConnection() ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) + ctx.provide('connection', this.connection) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). const renderer = createSlotRenderer() diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 01e7db4c3d..95f6574405 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -1,6 +1,5 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' import type { DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' @@ -99,21 +98,6 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined) } - /** - * Workspace-file URL (recorded). Runs the production path derivation so a - * feature test sees the real in/outside-workspace split; stub to force either. - * @param sessionId - the session whose cwd anchors the path. - * @param cwd - that session's working directory. - * @param path - the path a tool reported. - * @returns the origin-relative URL, or undefined outside the workspace. - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { - this.calls.push({ method: 'fileUrl', args: [sessionId, cwd, path] }) - const stub = this.stubs.get('fileUrl') - if (stub !== undefined) return stub(sessionId, cwd, path) as string | undefined - const segments = workspaceFileSegments(cwd, path) - return segments === undefined ? undefined : workspaceFileUrl(sessionId, segments) - } /** * Directory picker (recorded). The default cancels (null); stub to select. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index a9c4b0c9ca..3675671f26 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -549,10 +549,6 @@ describe('workspaces action face', () => { expect(renamed.title).toBe('Renamed') await ws.delete('w1' as WorkspaceId) await ws.openPath('/proj/file.ts') - // fileUrl runs the production derivation, so a feature test sees the same - // inside/outside-workspace split the browser half decides on. - expect(ws.fileUrl('s1' as SessionId, '/proj', 'out/a.html')).toBe('/f/s1/out/a.html') - expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBeUndefined() const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) expect(moved.sessionIds).toEqual(['s1']) // Default archive mirrors the production effect: the id joins the list @@ -560,15 +556,13 @@ describe('workspaces action face', () => { await ws.archiveSession('s1' as SessionId) expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( - ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl', - 'insertSessionBefore', 'archiveSession']) + ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession']) ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) ws.stub('pickDirectory', () => Promise.resolve('/picked')) ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never)) ws.stub('delete', () => Promise.resolve()) ws.stub('openPath', () => Promise.resolve()) - ws.stub('fileUrl', () => '/f/forced/a.html') ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) expect((await ws.create({ name: 'y' })).title).toBe('X') @@ -576,7 +570,6 @@ describe('workspaces action face', () => { expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) await ws.openPath('/other') - expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBe('/f/forced/a.html') expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) // The stub replaces the default set mutation: the set stays as-is. await ws.archiveSession('s2' as SessionId) diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json index 6a758c66f9..681bff474c 100644 --- a/packages/client/test-runtime/tsconfig.json +++ b/packages/client/test-runtime/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../web-react" }, + { + "path": "../connection" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2a2857839d..446c2084b1 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: ba55f0704500034b7afb37258064fe0801aaee91 -README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622 +README.md: 8c2075d615eccad1bbc7f5de1255ea4add69fab8 +README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ba55f07045..8c2075d615 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 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)). -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab, served by the web transport's `/f` route, so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab on the transport's workspace-file origin (`ConnectionHandle.fileUrl`), so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4212908b35..634721b424 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`ConnectionHandle.fileUrl`),因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 88c09b5550..55036600c6 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -39,6 +39,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -50,6 +51,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 71f05267b3..a65ce1d7a4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -42,7 +43,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection'] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -275,11 +276,12 @@ export function apply(ctx: Context): void { }, openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - // A file inside the workspace opens in a new tab, so a browser that - // is not on the Host machine can still see what the agent produced. - // Anything outside it has no served URL and falls back to the Host's - // own opener, which is loopback-only by the /api trust fence. - const url = workspaces.fileUrl(sessionId, cwd, path) + // A file inside the workspace opens in a new tab on the transport's + // workspace-file origin, so a browser that is not on the Host machine + // can still see what the agent produced. Anything outside it has no + // served URL and falls back to the Host's own opener, which is + // loopback-only by the /api trust fence. + const url = (ctx.get('connection') as ConnectionHandle).fileUrl(sessionId, cwd, path) if (url !== undefined) { window.open(url, '_blank', 'noopener,noreferrer') return diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 6427f6750c..b9dbe0d6ad 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -220,12 +220,15 @@ describe('conversation slot inject surface', () => { it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => { const b = await bench() + // A host that publishes a workspace-file port: previews come from that + // origin, which is what keeps them off the API's. + b.runtime.connection.filesPort = 4321 const open = vi.spyOn(window, 'open').mockReturnValue(null) const { injected } = b.chatViewSurface(ROOT) - // Inside the session cwd: served by this origin, so a browser anywhere on - // the network sees the file the agent produced. + // Inside the session cwd: served on the workspace-file origin, so a browser + // anywhere on the network sees the file the agent produced. injected.openFile('src/a.ts') - expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer') + expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer') expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false) // Outside it there is no served URL, so the Host's own opener answers — // resolved against the session cwd exactly as before. diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 51e31c9750..2763702c0b 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -134,9 +134,11 @@ async function bench(snapshot: ConversationSnapshot) { startSession: vi.fn(), sendSession: vi.fn(), openPath: vi.fn(async () => {}), - fileUrl: vi.fn((_sessionId: unknown, _cwd: string | undefined, path: string) => `/f/s-1/${path}`), } ctx.provide('workspaces', workspaces) + // The transport face the chat view reads its workspace-file URLs from. + const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) } + ctx.provide('connection', connection) ctx.provide('layout', layout) const locale = new LocaleService(ctx) ctx.provide('locale', locale) @@ -249,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => { view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') + expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') }) open.mockRestore() view.getByText('List notes').click() diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 6cb46e7ea0..84cdd53eeb 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -121,6 +121,7 @@ describe('keyed toolview hole through the real machinery', () => { it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => { const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')]) + b.runtime.connection.filesPort = 4321 const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = b.runtime.renderRoot() view.getByText('src/a.ts').click() diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 04b265bdd5..33d45124b4 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../web-react" }, + { + "path": "../connection" + }, { "path": "../runtime" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7073f04e4b..5fb7df52ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1183,6 +1183,9 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1263,6 +1266,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From 8fb6c2bd698d75a621912570663505d4dd0d42e4 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <zzy211851@gmail.com> Date: Sat, 1 Aug 2026 03:15:54 -0700 Subject: [PATCH 033/190] refactor(web): open produced files through the Host, not over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope decision: previews for a browser that is not on the Host machine are not supported. With that settled, host.openPath answers the supported case completely — a file:// document in a real browser has full page capabilities and no reach into /api — and the HTTP serving this branch had built answered only the unsupported one. Removed: the /f route and its listener, the workspace-file URL shape, ApiProxy.workspaceRootOf, ConnectionHandle.fileUrl, and the port published into the index page. Kept, and finished: - the produced-files row a turn ends with, derived from mutation locations; - the path link now reads as a link at rest, not only on hover — the reported "I can't open what it made" was this, sitting on a working capability; - the Host opener prefers the default BROWSER for .html/.htm/.xhtml/.svg, so a developer who binds .html to an editor still gets a rendered page (macOS via the LaunchServices https handler, Linux via $BROWSER, every failure falling back to the default application). The retired designs and their measurements stay in the Agent Note, including why same-origin serving was unsafe and why the sandbox that fixed it broke the pages invisibly. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 28 ++- .../2026-07-31-web-workspace-file-links.zh.md | 28 ++- apps/web/tests/produced-files.e2e.ts | 76 ++++++++ apps/web/tests/workspace-file-open.e2e.ts | 122 ------------- apps/web/tsconfig.json | 2 +- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 10 +- packages/client/connection/README.zh.md | 8 +- .../client/connection/src/client/fixture.ts | 4 - .../client/connection/src/client/index.ts | 25 --- .../client/connection/src/files-server.ts | 127 -------------- packages/client/connection/src/index.ts | 41 +---- .../client/connection/src/workspace-files.ts | 164 ------------------ .../connection/tests/client-apply.spec.ts | 26 +-- .../connection/tests/files-server.spec.ts | 44 ----- .../client/connection/tests/node-half.spec.ts | 114 ++---------- .../connection/tests/workspace-files.spec.ts | 142 --------------- .../client/runtime/tests/client-apply.spec.ts | 1 - .../client/runtime/tests/wire-events.spec.ts | 1 - packages/client/test-runtime/package.json | 2 - .../client/test-runtime/src/connection.ts | 48 ----- packages/client/test-runtime/src/index.ts | 6 - packages/client/test-runtime/tsconfig.json | 3 - .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 2 - .../ui-conversation/src/client/apply.ts | 13 +- .../src/client/chat/ToolRow.module.css | 13 +- .../tests/apply-inject.spec.tsx | 16 +- .../tests/chat-code-subcalls.spec.tsx | 7 +- .../tests/chat-toolview-slot.spec.tsx | 7 +- packages/client/ui-conversation/tsconfig.json | 3 - packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 - packages/host/apiproxy/README.zh.md | 2 - packages/host/apiproxy/src/api-proxy.ts | 15 -- packages/host/apiproxy/src/api/files.ts | 98 ----------- packages/host/apiproxy/src/api/index.ts | 17 -- packages/host/apiproxy/src/index.ts | 2 - .../host/apiproxy/src/native-path-opener.ts | 76 +++++++- .../tests/api-proxy-workspace.spec.ts | 32 +--- .../apiproxy/tests/client-handler.spec.ts | 2 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 - .../host/apiproxy/tests/files-path.spec.ts | 74 -------- .../apiproxy/tests/native-path-opener.spec.ts | 93 ++++++++++ pnpm-lock.yaml | 6 - tsconfig.host.json | 2 +- 50 files changed, 317 insertions(+), 1211 deletions(-) create mode 100644 apps/web/tests/produced-files.e2e.ts delete mode 100644 apps/web/tests/workspace-file-open.e2e.ts delete mode 100644 packages/client/connection/src/files-server.ts delete mode 100644 packages/client/connection/src/workspace-files.ts delete mode 100644 packages/client/connection/tests/files-server.spec.ts delete mode 100644 packages/client/connection/tests/workspace-files.spec.ts delete mode 100644 packages/client/test-runtime/src/connection.ts delete mode 100644 packages/host/apiproxy/src/api/files.ts delete mode 100644 packages/host/apiproxy/tests/files-path.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index e8510d24e5..2b75bc2eff 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: 8eeb96517aa905e77e50ce36f0efb704353c0821 -2026-07-31-web-workspace-file-links.zh.md: 63d746b0a9aabaf78ba5653e16705bd662a54126 +2026-07-31-web-workspace-file-links.md: da99426ecb5ca81dcc110bbd4d5c1218390ae4bd +2026-07-31-web-workspace-file-links.zh.md: 91aa94c6fe253c64125eb31fd15973a5aaff1a8f diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index 8eeb96517a..da99426ecb 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -4,36 +4,32 @@ Status: implemented English | [中文](2026-07-31-web-workspace-file-links.zh.md) -> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, the conversation's file-open affordance switching to it, and the produced-files row a finished turn ends with. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration. +> Scope: the produced-files row a finished turn ends with, the file-path link that reads as one, and the Host opener preferring the default browser for documents a browser renders. Not in scope, by decision: serving workspace files over HTTP, and previews for a client that is not on the Host machine. ## Problem A web session that produced a file had no way to look at it. The agent wrote `deepseek-homepage.html`, said so, and the user's only recourse was to copy an absolute path like `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` into a terminal. -The parts were nearly all present, pointed at the wrong target. `ToolRow` already renders a mutation or read row's path as a real button, `ui-conversation` already routes its click through `openFile`, and `workspaces.openPath` already carries it to the Host's system opener. But that opener runs on the Host machine, and `host.openPath` is loopback-pinned by the `/api` trust fence, so the affordance answered nothing for a browser reached over the LAN and was invisible even locally (the path styled as plain text, underlined only on hover). Meanwhile `MarkdownText` strips every non-`http(s)` URL, so a path the model wrote into its closing message could never become a link at all, and `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client. +Two distinct defects sat behind that. The transcript never said what a turn had produced: `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client, so a reader's only account of the output was whatever the closing message happened to spell. And the affordance that did exist was invisible: `ToolRow` already renders a mutation or read row's path as a real button wired to `host.openPath`, but styled exactly like the surrounding prose and underlined only on hover, so nobody found it. The reported "I can't open what it made" was a discoverability failure sitting on top of a working capability. ## Decision -**One prefix route on the transport that already exists, not a new capability.** `client-connection` owns both browser-facing prefixes: `/api` for RPC and `/f/<sessionId>/<segments…>` for workspace-file reads. It was already the package holding `httpServer`, the `trustedHosts` config, and the browser-trust fence; a separate package would have duplicated the fence and the config, and forced `AppCLIEntry` to patch two rows for one `--trusted-host` flag. The webserver's own contract — every feature surface is a route some other plugin registers — makes the route the whole mechanism. Segments ride the path rather than a query parameter so a served document's relative references resolve to its siblings. +**A finished turn ends with the files it produced.** `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the seq `assistantActionsSeqs` already elects. -**The request names a Session; the gateway names the authority.** `ApiProxy.workspaceRootOf` answers where a Session's files live — a live agent's `session.header.cwd` first, then the persistence store, never a resume — as a second, non-envelope face of the `cwd` the session summaries already carry. The route reads that instead of `ctx.agents` directly, because `client-connection` is registered in the client program and importing the core service packages merges their host-side `sessions: SessionStore` declaration over the browser runtime's own `sessions: SessionsService` — the collision `tsconfig.host.json`/`tsconfig.client.json` exist to prevent. Both the cwd and the resolved target go through `realpath` before the prefix comparison, so a workspace-internal symlink pointing outward is refused by its target; traversal spellings are refused at parse time, before any filesystem call. Reads stream through `pipeline`, so a client that goes away destroys the descriptor and no request ever buffers a file. +**The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. -**The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy. +**Opening stays the Host's job, and prefers the default browser.** `host.openPath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. Each platform answers "which browser" as completely as it can (macOS from the LaunchServices `https` handler, Linux from `$BROWSER`), and every failure falls back to the default application rather than surfacing. -**Workspace files get their own port, and therefore their own origin.** The isolation question was worked three ways before landing here. A sandbox header came first, on the reasoning that `/api/events.mux` is a readable same-origin `GET` stream. It was then dropped on the premise that these files are agent-authored, so a browser boundary would sit behind one already crossed — a premise review falsified: a read row makes every file in a cloned repository openable, and a same-origin active document was measured driving `/api/settings.describe` to a `200` with full data, reaching the loopback-pinned settings and credential plane from a page nobody in this session wrote. Restoring the sandbox closed that, and measuring what it cost decided the final shape: under `CSP: sandbox` the report's own artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its `<script>`, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still *looked* right. A second port is the boundary without the amputation: cross-origin to `/api` (refused by the fence's Origin check and by CORS), same-origin with itself (so `localStorage`, cookies, and `fetch` all work). It binds the same host as the API so LAN previews keep working, answers `/f` and nothing else, and publishes its port into the index page for the browser half to address. - -**The client decides by derivation, not by probing.** `ConnectionHandle.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns an absolute URL on the workspace-file origin — the page's own hostname, the published port — or `undefined` when the path leaves the workspace or no port was published. It lives on the connection handle because the transport owns both ends: the listener that serves the bytes and the port that addresses it. `undefined` is exactly the signal to fall back to `openPath`, which is also what makes the keyless fixture lane (served by no host) degrade to the old behavior instead of opening a dead tab. +**Serving workspace files over HTTP is out of scope, and so are non-local clients.** An earlier revision served files from the harness itself — first same-origin with `/api`, then behind `CSP: sandbox`, then from a second listener whose own port gave served documents their own origin. Each step answered a real problem, and the whole line was retired once the product scope was settled: previews for a browser that is not on the Host machine are not supported. With that decided, the Host opener answers the supported case completely and the HTTP machinery answered only the unsupported one. ## Alternatives considered -- **The artifact capability family (RFC #268 / PR #272)** — a seam with ids, versions, snapshot storage, its own HTTP server, SSE live reload, and a browser auto-opener. Its review found seven critical issues, and every one of them came from that machinery: an unlistened opener spawn crashing the harness, the opener inheriting `DEEPSEEK_API_KEY`, in-flight publishes outliving disposal, `readFile` preceding the size cap, a snapshot TOCTOU, and retention leaking with undisposed agents. `dsh web` already runs an HTTP server and the user is already in a browser, so none of that machinery buys anything here. The RFC and its tests stay as the input for the day a real cross-session or versioned-artifact need appears; this route is that seam's natural mount point when it does. -- **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule. -- **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route. -- **`/f/<absolute path>`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope. -- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under a sandbox** — measurably viable against the SSE-read vector (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), but it never addressed the larger one: same-origin `fetch` to a POST method is what reaches the configuration plane, and blocking `connect-src` from the served document is exactly what a hostile document would not do to itself. -- **Keeping the sandbox and accepting the limitation** — the honest reading of that trade only became visible once measured: it is not "a preview cannot remember a theme" but "a preview's entire script dies at its first storage access", on pages that still render perfectly. A limitation nobody can see is worse than one that costs a port. -- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead; linkifying the prose stays unnecessary rather than merely deferred. +- **Serving `/f/<sessionId>/<segments…>` from the harness** — built and working, including confinement by dual `realpath`, the browser-trust fence, streamed reads, and a separate listener whose port gave served documents their own origin. It is the only design that shows a preview to a client on another machine, which is exactly the case ruled out of scope. Retired for that reason, not because it failed; its cost was a second socket with its own lifecycle, a port published into the page, and a URL-shape contract shared across two packages. +- **Same-origin HTTP serving without isolation** — measurably unsafe, and recorded so nobody retries it: a document served beside `/api` drove `settings.describe` to a `200` with full data and `session.list` to 35 KB of every session's transcript, from a page that need not be agent-authored at all (a read row makes every file in a cloned repository openable). +- **`Content-Security-Policy: sandbox` over that same-origin serving** — closes the hole by taking the document's origin away, which measurably breaks the pages this feature exists to show: the reported artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its `<script>`, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still rendered perfectly, so the breakage was invisible. +- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead. +- **An embedded WebView in the desktop shell** — the strongest isolation available, since the preview then runs in a container the product owns rather than in the user's browser. It belongs to the desktop shell's own design, not to this surface, and is recorded here as the direction a future preview capability should take. ## Consequences -Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f/<sessionId>/a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview runs with its own origin's full capabilities, so a generated page behaves as its author intended. The residual the port does not close: two Sessions share one workspace-file origin, so a document from one may fetch another's served files. That is strictly narrower than the API surface it replaces, and narrowing it further would mean an origin per Session, which nothing today needs. The produced-files row ships here too: `turnDeliverables` reads a turn's output off the mutation tools' render intent (a diff card, or a generic card whose `kind` is `edit`), resets on the turn boundary so an interrupted turn cannot spill into the next, and renders under the closing assistant. Still deferred: linkification inside assistant Markdown, and any cross-session view of past deliverables. +Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and the browser preference apply to all of them without a per-row change. The keyless web lane (`apps/web/tests/produced-files.e2e.ts`) cold-seeds a recorded write turn and pins the row in the assembled application; it deliberately does not click, because the click hands a path to the Host's opener and would launch a real application on the machine running the suite. A produced file opens as a `file://` document, which cannot `fetch` its own siblings (a multi-file artifact that loads `./data.json` breaks, while `<script src>`, `<img>`, and CSS `@import` are unaffected) — the one capability HTTP serving had that this does not. A client reached over the network sees nothing when it clicks: `host.openPath` runs on the Host and is loopback-pinned by the `/api` trust fence. That is the scope decision showing through, not a defect, and it is why the row keeps the full path in its `title` for a reader who can only copy it. Markdown opens in whatever the platform hands `.md`, usually an editor rather than a renderer; rendering it inside the product is a separate, deferred surface. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 63d746b0a9..91aa94c6fe 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -4,36 +4,32 @@ Status: implemented [English](2026-07-31-web-workspace-file-links.md) | 中文 -> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导、会话中打开文件的交互改指向它,以及完成的一轮以其产出文件收尾的那一行。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 +> 范围:完成的一轮以其产出文件收尾的那一行、读得出是链接的文件路径链接,以及 Host 打开器对浏览器可渲染文档优先选用默认浏览器。经决定不在范围内:以 HTTP 提供工作区文件,以及为不在 Host 机器上的客户端提供预览。 ## 问题 一个产出了文件的 web 会话,没有办法看到那个文件。agent 写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。 -零件几乎都在,只是指错了目标。`ToolRow` 早已把改写行或读取行的路径渲染成一个真正的按钮,`ui-conversation` 早已把它的点击经由 `openFile` 转发,`workspaces.openPath` 也早已把它送到 Host 的系统打开器。但那个打开器运行在 Host 机器上,而 `host.openPath` 被 `/api` 信任 fence 钉在回环,所以这个交互对经 LAN 访问的浏览器什么都答不了,即便在本机也是隐形的(路径的样式就是普通文本,只有 hover 时才有下划线)。与此同时 `MarkdownText` 会剥掉每一个非 `http(s)` 的 URL,因此模型写进收尾消息里的路径根本不可能成为链接;而 `ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方。 +这背后是两个不同的缺陷。转录从不说明一轮产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把改写行或读取行的路径渲染成一个接到 `host.openPath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的“做完了打不开”,是一个可发现性失败叠在一项本就可用的能力之上。 ## 决定 -**在已有的传输层上加一条前缀路由,而不是加一项能力。** `client-connection` 持有两条面向浏览器的前缀:`/api` 承载 RPC,`/f/<sessionId>/<segments…>` 承载工作区文件读取。它本来就是持有 `httpServer`、`trustedHosts` 配置和浏览器信任 fence 的那个包;单开一个包会把 fence 和配置各复制一份,并逼着 `AppCLIEntry` 为一个 `--trusted-host` 标志去 patch 两行。webserver 自己的契约——每个特性面都是别的插件注册的一条路由——让这条路由本身就是全部机制。段落走路径而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。 +**完成的一轮以它产出的文件收尾。** `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 早已选出的那个 seq。 -**请求指名 Session,由网关指名权限边界。** `ApiProxy.workspaceRootOf` 回答某个 Session 的文件位于何处——先看活跃 agent 的 `session.header.cwd`,再看持久化存储,绝不恢复会话——它是会话摘要早已携带的那个 `cwd` 的第二副面孔,只是不带信封。路由读取它而不是直接够 `ctx.agents`,因为 `client-connection` 注册在 client 程序里,而引入核心服务包会把它们 host 侧的 `sessions: SessionStore` 声明盖到浏览器运行时自己的 `sessions: SessionsService` 之上——这正是 `tsconfig.host.json`/`tsconfig.client.json` 分立所要防的那种冲突。cwd 与解析出的目标在前缀比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝;穿越写法在解析期就被拒,早于任何文件系统调用。读取经 `pipeline` 流出,因此客户端离开即销毁描述符,任何请求都不会把文件缓冲起来。 +**路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 -**URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。 +**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。每个平台在自己能力范围内回答“哪个浏览器”(macOS 取 LaunchServices 的 `https` 处理程序,Linux 取 `$BROWSER`),任何一步失败都回退到默认应用,而不是把失败抛给用户。 -**工作区文件获得自己的端口,因而拥有自己的源。** 隔离这件事在落到此处之前走了三步。最初是加 sandbox 头,理由是 `/api/events.mux` 是一条同源可读的 `GET` 流。随后它被拿掉,前提是这些文件由 agent 撰写、浏览器边界只会立在一条早已越过的边界之后——而评审推翻了这个前提:一条 read 行就让 clone 下来的仓库里任何文件变得可打开,而同源的活动文档经实测能把 `/api/settings.describe` 打到 `200` 并拿到完整数据,从一个本次会话中无人撰写的页面触达了被钉在回环的设置与凭据面。加回 sandbox 堵住了它,而“量清楚它的代价”决定了最终形状:在 `CSP: sandbox` 之下,报告中那份产物加载时就抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们**看上去**仍然正常。第二个端口给出了这条边界而无需截肢:对 `/api` 是跨源(被 fence 的 Origin 校验与 CORS 双重拒绝),对自身是同源(因此 `localStorage`、cookie 与 `fetch` 都可用)。它绑定与 API 相同的 host,因此 LAN 预览继续可用;只应答 `/f`,别无其他;并把端口注入首页供浏览器半侧寻址。 - -**客户端靠推导决定,而不是靠探测。** `ConnectionHandle.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落,并返回工作区文件源上的绝对 URL——页面自身的主机名,加上已发布的端口——路径离开工作区或没有端口发布时返回 `undefined`。它落在 connection 句柄上,是因为传输层同时持有两端:提供字节的监听器,和寻址它的端口。`undefined` 恰好就是回退到 `openPath` 的信号,这也让无密钥 fixture 通道(不由任何 host 提供)退化为旧行为,而不是打开一个空标签页。 +**以 HTTP 提供工作区文件不在范围内,非本机客户端亦然。** 更早的一版由 harness 自己提供文件——先是与 `/api` 同源,随后加上 `CSP: sandbox`,再后来交给一个以自身端口给所服务文档独立源的第二监听器。每一步都在回答一个真实问题,而整条线在产品范围定下之后被整体退役:不为“浏览器不在 Host 机器上”的场景提供预览。这一点定下之后,Host 打开器完整回答了受支持的场景,而那套 HTTP 机制回答的只是不受支持的那个。 ## 考虑过的替代方案 -- **产物能力族(RFC #268 / PR #272)**——一条带 id、版本、快照存储、自有 HTTP 服务器、SSE 实时重载与浏览器自动打开器的 seam。它的评审给出了七个 critical,而每一个都来自那套机械结构:未监听的打开器 spawn 会让 harness 崩溃、打开器继承 `DEEPSEEK_API_KEY`、进行中的 publish 活过 dispose、`readFile` 先于大小上限、快照的 TOCTOU,以及未 dispose 的 agent 导致保留期泄漏。`dsh web` 本来就跑着一个 HTTP 服务器,用户本来就在浏览器里,那套机械结构在这里买不到任何东西。RFC 与其测试保留下来,作为真正出现跨会话或版本化产物需求那天的输入;届时这条路由就是那条 seam 的天然挂载点。 -- **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。 -- **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。 -- **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。 -- **用 `connect-src 'none'` 加一道导航栅栏,在 sandbox 之下保住 `localStorage`**——针对“读走 SSE 流”这条向量经实测可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但它从未触及更大的那条:真正够到配置面的是向 POST 方法发起的同源 `fetch`,而“从所服务文档一侧封住 `connect-src`”恰恰是敌意文档不会对自己做的事。 -- **保留 sandbox 并接受这条限制**——这笔交易的真实读数要量过才看得见:它不是“预览记不住主题”,而是“预览的整段脚本在第一次访问存储时就死了”,而页面照样渲染得完美无缺。一条没人看得见的限制,比一条要花掉一个端口的限制更糟。 -- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它;因此把正文链接化是不必要,而不只是被推迟。 +- **由 harness 提供 `/f/<sessionId>/<segments…>`**——已经实现并可用,包含双 `realpath` 收敛、浏览器信任 fence、流式读取,以及一个以自身端口给所服务文档独立源的监听器。它是唯一能把预览呈现给另一台机器上客户端的设计,而那恰恰是被判出范围的场景。因此退役,而不是因为它失败了;它的代价是一个带自身生命周期的第二 socket、一个注入页面的端口,以及一份跨两个包共享的 URL 形状契约。 +- **同源 HTTP 提供且不加隔离**——经实测不安全,记录在此以免有人重试:与 `/api` 并排提供的文档把 `settings.describe` 打到 `200` 并拿到完整数据,把 `session.list` 打到 35 KB 的全部会话转录,而这个页面根本不必由 agent 撰写(一条 read 行就让 clone 下来的仓库里任何文件变得可打开)。 +- **在那套同源提供之上加 `Content-Security-Policy: sandbox`**——它以剥夺文档的源来堵住这个洞,而这经实测会破坏本功能存在的意义所在的那类页面:所报告的产物在加载时抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们渲染得完美无缺,所以这种破坏是看不见的。 +- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它。 +- **桌面端外壳中的内嵌 WebView**——可得到的最强隔离,因为那时预览跑在产品自己拥有的容器里,而不是用户的浏览器里。它属于桌面端外壳自身的设计,而非本交互面,记录在此作为未来预览能力应走的方向。 ## 影响 -现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f/<sessionId>/a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览以自身源的完整能力运行,因此生成的页面按其作者的意图工作。端口没有堵住的残余:两个 Session 共用同一个工作区文件源,因此来自其一的文档可以 fetch 另一个已服务的文件。这比它所替代的 API 面严格更窄,而要再窄一层就意味着每个 Session 一个源,今天没有任何需求指向那里。产出文件行也在本次一并落地:`turnDeliverables` 依据改写工具的渲染意图(diff 卡片,或 `kind` 为 `edit` 的 generic 卡片)读出一轮的产出,在 turn 边界重置以免中断的一轮溢进下一轮,并渲染在收尾 assistant 之下。仍然暂缓:助手 Markdown 内部的链接化,以及任何跨会话回看既往产物的视图。 +现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接可见性修复与浏览器优先策略无需逐行改动即适用于全部。无密钥 web 通道(`apps/web/tests/produced-files.e2e.ts`)冷播种一段录制的 write 轮次,在组装后的应用中钉住该行;它刻意不点击,因为点击会把路径交给 Host 打开器,从而在跑测试的机器上启动一个真实应用。产出的文件以 `file://` 文档打开,它无法 `fetch` 自己的同级文件(一个加载 `./data.json` 的多文件产物会坏,而 `<script src>`、`<img>` 与 CSS `@import` 不受影响)——这是 HTTP 提供曾有、而此处没有的那一项能力。经网络访问的客户端点击后看不到任何东西:`host.openPath` 在 Host 上运行,且被 `/api` 信任 fence 钉在回环。那是范围决定的显现,不是缺陷,也正因如此该行把完整路径保留在 `title` 中,供只能复制它的读者使用。markdown 会由平台交给 `.md` 的默认处理程序打开,通常是编辑器而非渲染器;在产品内渲染它是另一个被推迟的交互面。 diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts new file mode 100644 index 0000000000..c96131bf69 --- /dev/null +++ b/apps/web/tests/produced-files.e2e.ts @@ -0,0 +1,76 @@ +// Web e2e scenario: the produced-files row a finished turn ends with. Cold-seeds +// a recorded write turn (zero model calls). Package tests cover the derivation +// in isolation, but only the assembled application shows that a turn's writes +// reach the transcript as an openable row (docs/testing.md snapshot rule). The +// click itself is not driven here: it hands the path to the Host's opener, +// which would launch a real application on the machine running the suite. +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a +// file, not a new recording (the message-actions borrowing pattern). +const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'produced-files-web-e2e' + +/** The file the borrowed recording's write tool produces. */ +const PRODUCED = 'policy-neutral.txt' + +describe('web e2e: a finished turn ends with the files it produced', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // The seeded Session's cwd is the scaffold workspace; the recording's own + // nested directory is created too, so its paths stay resolvable. + await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) + await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') + const raw = await readFile(SEED, 'utf8') + expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) + await seedSession(scaffold, raw, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('lists the written file under the closing message, as an opener', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // The row the turn ends with — derived from the write call's locations, + // not from whatever the closing message happened to say. + const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() + await chip.waitFor({ timeout: 15_000 }) + expect(await chip.innerText()).toBe(PRODUCED) + // The full path stays reachable for a reader who wants to copy it. + expect(await chip.getAttribute('title')).toContain(PRODUCED) + // A turn's produced files are labelled, not left as bare chips. + expect(await page.getByText('Produced', { exact: true }).count()).toBeGreaterThan(0) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tests/workspace-file-open.e2e.ts b/apps/web/tests/workspace-file-open.e2e.ts deleted file mode 100644 index 8f6bd6f665..0000000000 --- a/apps/web/tests/workspace-file-open.e2e.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Web e2e scenario: a produced file, from the row that lists it to the bytes -// the browser gets. Cold-seeds a recorded write turn (zero model calls). -// Package tests cover the derivation and the route in isolation, but only the -// assembled application shows that the turn's Produced row, the URL it opens, -// and the file on disk are the same thing (docs/testing.md snapshot rule). -import { readFile, writeFile, mkdir } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a -// file, not a new recording (the message-actions borrowing pattern). -const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) -const MODE = webSnapshotMode() -const SEED_ID = 'workspace-file-open-web-e2e' - -/** The file the borrowed recording's write tool produces. */ -const PRODUCED = 'policy-neutral.txt' -/** An active document placed alongside it, for the isolation header the route puts on those. */ -const ACTIVE = 'preview.html' - -describe('web e2e: opening a produced file from the conversation', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType<typeof watchConsole> - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - // The seeded Session's cwd is the scaffold workspace; the recording's own - // nested directory is created too, so its paths stay resolvable. - await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) - await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') - await writeFile(join(scaffold.workspaceCwd, ACTIVE), '<h1>produced</h1>\n') - const raw = await readFile(SEED, 'utf8') - expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) - await seedSession(scaffold, raw, SEED_ID) - browser = await chromium.launch() - page = await newEnglishPage(browser) - tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it.skipIf(MODE === 'record')('ends the turn with its produced file, which opens as the workspace file itself', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() - - // The row the turn ends with — derived from the write call's locations, - // not from whatever the closing message happened to say. - const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() - await chip.waitFor({ timeout: 15_000 }) - expect(await chip.innerText()).toBe(PRODUCED) - - const [opened] = await Promise.all([ - page.context().waitForEvent('page', { timeout: 15_000 }), - chip.click(), - ]) - await opened.waitForLoadState('domcontentloaded') - const url = new URL(opened.url()) - expect(url.pathname).toBe(`/f/${SEED_ID}/${PRODUCED}`) - expect(await opened.locator('body').innerText()).toContain('neutral') - - // The isolation: previews come from the app's hostname on a DIFFERENT - // port, so a served document is cross-origin to /api while keeping its own - // capabilities. A workspace file is not necessarily agent-authored. - const app = new URL(scaffold.baseUrl) - expect(url.hostname).toBe(app.hostname) - expect(url.port).not.toBe(app.port) - const filesOrigin = url.origin - - const served = await page.request.get(opened.url()) - expect(served.status()).toBe(200) - expect(served.headers()['x-content-type-options']).toBe('nosniff') - expect(served.headers()['cache-control']).toBe('no-store') - // No document is stripped of its origin: the port is the boundary. - expect(served.headers()['content-security-policy']).toBeUndefined() - - // An active document keeps its own storage — the capability a sandbox - // header would have taken, and the reason this route has its own port. - const active = opened - await active.goto(`${filesOrigin}/f/${SEED_ID}/${ACTIVE}`, { waitUntil: 'load' }) - expect(await active.evaluate(() => { - try { window.localStorage.setItem('probe', '1'); return 'ok' } catch { return 'blocked' } - })).toBe('ok') - // …and cannot reach the API, which lives on the other origin. - expect(await active.evaluate(async (base) => { - try { - await fetch(`${base}/api/session.list`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ type: 'client-request', rpcId: 'x', method: 'session.list', payload: {} }), - }) - return 'reached' - } catch { return 'blocked' } - }, scaffold.baseUrl)).toBe('blocked') - - // The workspace-file origin serves that one prefix and nothing else. - expect((await page.request.get(`${filesOrigin}/`)).status()).toBe(404) - // Nothing outside the Session's workspace is reachable through the route. - expect((await page.request.get(`${filesOrigin}/f/${SEED_ID}/..%2Fetc%2Fhosts`)).status()).toBe(404) - - await active.close() - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }, 90_000) -}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2c65f1e510..48db884aad 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -51,7 +51,7 @@ "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", - "tests/workspace-file-open.e2e.ts" + "tests/produced-files.e2e.ts" ], "references": [ { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7c21713f01..a356a151e3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -296,7 +296,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:25`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index af7d3d590d..682314605d 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 5001da2458ea3470659f5983dffc8de832aadeac -README.zh.md: 47e745964e4087c6ccc59aae5bbfba69f96480e4 +README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d +README.zh.md: f36cb4c4c6856089751e4492eb6e4b8e22abde56 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 5001da2458..c8b7c4787c 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,22 +2,14 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half owns both browser-facing prefixes — `/api` for RPC and `/f` for workspace-file reads — behind one trust fence. The `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). -## /f workspace-file reads - -The node half also serves one file at a time out of a Session's workspace under `/f/<sessionId>/<segments…>`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file. - -Workspace files are served from their own port, and therefore their own origin. That port is the isolation: a workspace file is not necessarily agent-authored — a read row makes every file in a cloned repository openable — so an active document served beside `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. A different origin closes that without touching the document: a preview keeps `localStorage`, cookies, and its own `fetch`, while a call to the API is cross-origin and refused twice over — by the fence's Origin check and by CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same boundary by taking the document's origin away entirely, which measurably breaks the pages this route exists to show (a page that reads `localStorage` throws on load, and every listener declared after that line in the same script never binds). The listener binds the same host as the API, so a client that can reach the app can reach its previews; it answers the `/f` prefix and nothing else — no index, no SPA fallback, no API — and its port is published into the index page as `window.__DSH_FILES_PORT__`, which the browser half reads to address it. The same trust fence gates it, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. - ## Keyless fixture -A fixture page is served by no host, so no workspace-file port is published into it and `ConnectionHandle.fileUrl` answers `undefined` — a file-path row falls back to the Host opener rather than opening a dead tab. - Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 47e745964e..f36cb4c4c6 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,18 +2,12 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧持有两条面向浏览器的前缀——`/api` 承载 RPC,`/f` 承载工作区文件读取——共用同一道信任 fence。`/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 -## /f 工作区文件读取 - -node 半侧还会在 `/f/<sessionId>/<segments…>` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 - -工作区文件由它自己的端口提供,因而拥有自己的源。那个端口就是隔离:工作区文件未必由 agent 撰写——一条 read 行就能让 clone 下来的仓库里任何文件变得可打开——因此与 `/api` 并排提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。换一个源即可堵死这条,且不必动文档本身:预览保有 `localStorage`、cookie 与自己的 `fetch`,而对 API 的调用属于跨源,会被两道独立的关卡拒绝——fence 的 Origin 校验,以及 CORS。另一种做法 `Content-Security-Policy: sandbox` 用"干脆剥夺文档的源"换来同一条边界,而这经实测会破坏本路由存在的意义所在的那类页面(读 `localStorage` 的页面在加载时抛异常,同一 script 块中该行之后声明的所有监听器都不会绑定)。该监听器绑定与 API 相同的 host,因此能访问应用的客户端也能访问它的预览;它只应答 `/f` 前缀,别无其他——没有首页、没有 SPA 兜底、没有 API——其端口以 `window.__DSH_FILES_PORT__` 注入首页,由浏览器半侧读取来寻址。它由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 - ## 无密钥 fixture fixture 页面不由任何 host 提供,因此没有工作区文件端口注入其中,`ConnectionHandle.fileUrl` 应答 `undefined`——文件路径行会回退到 Host 打开器,而不是打开一个空标签页。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f5fa3e34ea..0549fc1160 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2363,10 +2363,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return Promise.resolve({ accepted: true }) }, - // The fixture has no filesystem behind its Sessions, so it names no - // directory for any of them; the /f route belongs to the node half, which - // a fixture page never reaches. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 004b9fe286..e286157e46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -4,9 +4,6 @@ * controller with its sinks. */ import type { Context } from 'cordis' -import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { FILES_PORT_GLOBAL } from '../files-server.ts' import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' @@ -59,19 +56,6 @@ export interface ConnectionHandle { * @returns stop handle for the loop. */ start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void } - /** - * Absolute URL serving one file out of a Session's workspace, on the - * transport's own workspace-file origin — the same hostname the page is - * reached by, a different port, so a served document is isolated from this - * API without being stripped of its own capabilities. - * @param sessionId - the Session whose cwd anchors the path. - * @param cwd - that Session's working directory, or `undefined` when unknown. - * @param path - the path a tool reported (absolute, or relative to `cwd`). - * @returns the URL, or `undefined` when the path lies outside the workspace - * (which this transport never serves) or when this page was not served by a - * host that published a workspace-file port (the fixture carrier). - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined } /** @@ -84,15 +68,6 @@ export function apply(ctx: Context): void { let started = false const handle: ConnectionHandle = { api, - fileUrl(sessionId, cwd, path) { - // Published by the node half's index tap; absent means no host is - // serving workspace files to this page (the keyless fixture lane). - const port = (globalThis as unknown as Record<string, unknown>)[FILES_PORT_GLOBAL] - if (typeof port !== 'number') return undefined - const segments = workspaceFileSegments(cwd, path) - if (segments === undefined) return undefined - return `${location.protocol}//${location.hostname}:${String(port)}${workspaceFileUrl(sessionId, segments)}` - }, start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/files-server.ts b/packages/client/connection/src/files-server.ts deleted file mode 100644 index 73807c0810..0000000000 --- a/packages/client/connection/src/files-server.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * The workspace-file listener: a second loopback/LAN socket on the same host - * as the API, serving nothing but `/f`. - * - * The port is the isolation. A workspace file is not necessarily - * agent-authored — a read row makes every file in a cloned repository - * openable — so an active document must not be same-origin with `/api`, where - * its script would pass the browser-trust fence into every method, the - * loopback-pinned settings and credential plane included. A different port is - * a different origin, which the browser enforces for free: the document keeps - * `localStorage`, cookies, and its own `fetch`, while a call to the API is - * cross-origin and refused twice over — by the fence's Origin check and by - * CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same - * boundary by taking the document's origin away entirely, which measurably - * breaks the pages this route exists to show. - */ - -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' -import type { AddressInfo } from 'node:net' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' -import { isTrustedApiRequest } from './api-request-trust.ts' -import { handleWorkspaceFile, type WorkspaceFileDeps } from './workspace-files.ts' - -/** A listening workspace-file server: its port, and the teardown that reaches quiescence. */ -export interface FilesServer { - /** The bound port (OS-assigned), which the browser half needs to address this origin. */ - port: number - /** Close the socket and destroy held connections; resolves once quiet. */ - close: () => Promise<void> -} - -/** - * Bind the workspace-file listener. - * @param host - the same bind host the API uses, so a client that can reach - * the app can reach its previews (a LAN deployment included). - * @param trustedHosts - the deployment's non-loopback serving authorities, - * applied through the same fence as `/api`. - * @param deps - the session-to-directory lookup reads are confined by. - * @param onSocketError - reports a post-listen socket error; without a - * listener node would raise it as an unhandled 'error' event. - * @returns the bound port and its disposer. - */ -export async function listenForWorkspaceFiles( - host: string, - trustedHosts: readonly string[], - deps: WorkspaceFileDeps, - onSocketError: (error: Error) => void, -): Promise<FilesServer> { - const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => { - if (!isTrustedApiRequest(req, trustedHosts)) { - res.writeHead(403) - res.end('forbidden') - return - } - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - // This origin serves one prefix and nothing else: no index, no SPA - // fallback, no API. Anything else is not here — answered before the method - // check, because a 405 would claim the resource exists. - if (pathname !== FILES_PATH && !pathname.startsWith(`${FILES_PATH}/`)) { - res.writeHead(404) - res.end() - return - } - if (req.method !== 'GET' && req.method !== 'HEAD') { - // RFC 9110 §15.5.6: a 405 names the methods the resource does support. - res.writeHead(405, { allow: 'GET, HEAD' }) - res.end() - return - } - await handleWorkspaceFile(req, res, deps) - } - - const server: Server = createServer((req, res) => { - handle(req, res).catch((error: unknown) => { - // A malformed request must not become an unhandled rejection that takes - // the process down; the API carrier guards its own handler the same way. - if (res.headersSent) { - res.destroy() - return - } - onSocketError(error instanceof Error ? error : new Error(String(error))) - res.writeHead(400) - res.end() - }) - }) - - await new Promise<void>((resolve, reject) => { - server.once('error', reject) - server.listen(0, host, () => { - server.off('error', reject) - server.on('error', onSocketError) - resolve() - }) - }) - - return { - port: (server.address() as AddressInfo).port, - // close + closeAllConnections: a held-open response would otherwise keep - // teardown waiting forever. - close: () => new Promise<void>((resolve) => { - server.close(() => { resolve() }) - server.closeAllConnections() - }), - } -} - -/** The global the node half hands its port to the browser half through. */ -export const FILES_PORT_GLOBAL = '__DSH_FILES_PORT__' - -/** - * Inject the workspace-file port into index.html, ahead of the shell bundle - * that reads it. A boot-time fact of the serving host, delivered the way the - * module graph is: synchronously on the page, so the first click on a produced - * file does not race a round trip. - * @param html - the index.html source. - * @param port - the bound workspace-file port. - * @returns the html with the port script injected. - */ -export function injectFilesPort(html: string, port: number): string { - const script = `<script>window.${FILES_PORT_GLOBAL} = ${String(port)}</script>` - const head = html.indexOf('<head>') - if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` - /* v8 ignore next -- headless fixture pages may lack <head>; prepending keeps read-before-shell ordering. */ - return `${script}${html}` -} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f0a60bbfb9..03f8aaa257 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,16 +1,11 @@ -/** Host HTTP bridge for browser-client RPC and workspace-file reads. */ +/** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -// The merge-free types subpath: pulling the session package's root into this -// client-registered program would merge the host `sessions` service over the -// browser runtime's own. -import type { SessionId } from '@deepseek-ai/dsh-session/types' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -66,17 +61,15 @@ const PRIVILEGED_METHODS = new Set([ ]) /** - * Mounts the API gateway and the workspace-file reads under the browser - * transport prefixes. Every request on either prefix passes the browser-trust - * fence first (DNS-rebinding and cross-site defense — - * [api-request-trust](./api-request-trust.ts)); privileged methods - * additionally pass it with an empty trust list, which pins them to loopback. + * Mounts the API gateway under the browser transport prefix. Every request on + * the prefix passes the browser-trust fence first (DNS-rebinding and + * cross-site defense — [api-request-trust](./api-request-trust.ts)); + * privileged methods additionally pass it with an empty trust list, which + * pins them to loopback. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). - * @returns a promise settling once the workspace-file listener is bound and - * its port published — the page must never render before it can address one. */ -export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> { +export function apply(ctx: Context, config?: ConnectionConfig): void { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] // Config boundary: a malformed entry fails the load loudly here rather than @@ -104,24 +97,4 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - // The gateway is the host's session authority: it answers where a Session's - // files live without this package reaching into the core services, which - // would merge their host-side Context declarations into the browser lane. - const cwdFor = (sessionId: string): Promise<string | undefined> => - ctx.apiProxy.workspaceRootOf(sessionId as SessionId) - // Workspace files get their own port, and therefore their own origin: an - // active document served beside `/api` would reach every method through the - // fence below. The listen is awaited inside the effect so the port is known - // before the index tap that publishes it can run. - await ctx.effect(async () => { - const files = await listenForWorkspaceFiles( - ctx.httpServer.host, trustedHosts, { cwdFor }, - (error) => { ctx.logger.error(error) }, - ) - const untap = ctx.httpServer.tapIndex(html => injectFilesPort(html, files.port)) - return async () => { - untap() - await files.close() - } - }, 'client-connection: /f listener') } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts deleted file mode 100644 index c934b14516..0000000000 --- a/packages/client/connection/src/workspace-files.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * The read half of the web transport: streams one file out of a session's - * workspace so the browser can open what the agent just produced. The RPC - * gateway carries structured session state; this route carries bytes, which a - * JSON-RPC envelope cannot stream and a `file://` link cannot reach from an - * http page. - * - * Confinement is the whole contract: a request names a session, the session - * names its cwd, and nothing outside that realpath is ever served. The caller - * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — - * this module is reached only by requests that already passed it. - * - * Isolation is the listener's, not this module's: these responses carry no - * sandbox header because they are served from their own port, and therefore - * their own origin ([files-server](./files-server.ts)). A served document - * keeps `localStorage`, cookies, and its own `fetch`, while the API stays - * cross-origin to it. - */ - -import { createReadStream } from 'node:fs' -import { realpath, stat } from 'node:fs/promises' -import type { IncomingMessage, ServerResponse } from 'node:http' -import { extname, resolve, sep } from 'node:path' -import { pipeline } from 'node:stream/promises' -import { parseWorkspaceFilePath } from '@deepseek-ai/dsh-host-apiproxy/api' - -/** - * Content types served verbatim. Everything absent is `text/plain`, not - * `application/octet-stream`: a workspace read is a "show me what you made" - * gesture, and an unknown extension is far more often a source file to read - * than a binary to download. `nosniff` keeps that choice binding, so a - * mislabelled document can never be re-interpreted as HTML. - */ -const MIME: Record<string, string> = { - '.html': 'text/html; charset=utf-8', - '.htm': 'text/html; charset=utf-8', - '.xhtml': 'application/xhtml+xml', - '.svg': 'image/svg+xml', - '.css': 'text/css; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.json': 'application/json', - '.pdf': 'application/pdf', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.avif': 'image/avif', - '.ico': 'image/x-icon', - '.mp4': 'video/mp4', - '.webm': 'video/webm', - '.mp3': 'audio/mpeg', - '.wav': 'audio/wav', - '.wasm': 'application/wasm', -} - -const DEFAULT_MIME = 'text/plain; charset=utf-8' - -/** How the route learns which directory a session may serve from. */ -export interface WorkspaceFileDeps { - /** - * The session's absolute working directory. - * @param sessionId - the session named by the request path. - * @returns its cwd, or `undefined` when the id names no session this host serves. - */ - cwdFor: (sessionId: string) => Promise<string | undefined> -} - -function fail(res: ServerResponse, status: number): void { - res.writeHead(status) - res.end() -} - -/** - * Resolve one request's segments against a session cwd, refusing anything that - * leaves it. Both sides go through `realpath`, so a symlink inside the - * workspace pointing out of it is refused by its resolved target rather than - * its name. A component swapped between this resolution and the open below - * would still be followed; closing that window needs privileges that already - * imply workspace write access, which is strictly stronger than reading a - * workspace file, so the check stops here. - */ -async function confine(cwd: string, segments: readonly string[]): Promise<string | undefined> { - const root = await realpath(cwd) - // A filesystem root already ends in the separator; appending a second one - // would make every child fail the prefix test and 403 the whole workspace. - const prefix = root.endsWith(sep) ? root : root + sep - const real = await realpath(resolve(root, ...segments)) - return real.startsWith(prefix) ? real : undefined -} - -/** - * Serve one workspace-file request. The caller has already applied the - * browser-trust fence and rejected non-read methods. - * @param req - the request, read for its url and method only (no body). - * @param res - the response this function owns to completion. - * @param deps - the session-to-cwd lookup this host answers with. - */ -export async function handleWorkspaceFile( - req: IncomingMessage, - res: ServerResponse, - deps: WorkspaceFileDeps, -): Promise<void> { - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const target = parseWorkspaceFilePath(pathname) - if (target === undefined) { - fail(res, 404) - return - } - const cwd = await deps.cwdFor(target.sessionId) - if (cwd === undefined) { - fail(res, 404) - return - } - - let file: string | undefined - let size: number - try { - file = await confine(cwd, target.segments) - if (file === undefined) { - fail(res, 403) - return - } - const info = await stat(file) - // A directory read has no answer here: the route serves files, and listing - // is the directory-picker capability's job, behind its own fence. - if (!info.isFile()) { - fail(res, 404) - return - } - size = info.size - } catch { - // Missing, unreadable, or a path whose ancestor is not a directory: all - // report as absent, so a probe cannot distinguish them. - fail(res, 404) - return - } - - const ext = extname(file).toLowerCase() - res.writeHead(200, { - 'content-type': MIME[ext] ?? DEFAULT_MIME, - 'content-length': String(size), - 'content-disposition': 'inline', - 'x-content-type-options': 'nosniff', - // Workspace files change under the agent's hands; a cached preview would - // show the previous turn's output after the next edit. - 'cache-control': 'no-store', - }) - if (req.method === 'HEAD') { - res.end() - return - } - try { - // pipeline (not pipe) so a client disconnect destroys the read stream: - // an abandoned preview must not leave a descriptor open. - await pipeline(createReadStream(file), res) - } catch { - // The status line is already out, so a mid-stream read failure or client - // disconnect can only end the response abruptly. - res.destroy() - } -} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 4b323182bb..f9fe1c1b71 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,11 +8,10 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number } +type Win = { location?: { search: string } } afterEach(() => { delete (globalThis as Win).location - delete (globalThis as Win).__DSH_FILES_PORT__ }) async function mount(): Promise<ConnectionHandle> { @@ -64,27 +63,4 @@ describe('connection client apply', () => { expect(seen.some(u => u.includes('/api/'))).toBe(true) }) - it('addresses a workspace file on the port the host published, and only inside the workspace', async () => { - const win = globalThis as Win - win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' } - win.__DSH_FILES_PORT__ = 4321 - const handle = await mount() - const session = 's-1' as never - // Same hostname the page was reached by — a LAN client must reach previews - // too — and the published port, which is what makes it another origin. - expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')) - .toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html') - // Outside the workspace there is nothing this transport may serve, which - // is the signal a caller falls back to openPath on. - expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined() - }) - - it('serves no file URL on a page no host published a port into', async () => { - const win = globalThis as Win - win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' } - const handle = await mount() - // The keyless fixture lane: no workspace-file origin exists, so the row - // falls back to the Host opener instead of opening a dead tab. - expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined() - }) }) diff --git a/packages/client/connection/tests/files-server.spec.ts b/packages/client/connection/tests/files-server.spec.ts deleted file mode 100644 index 4a2618709b..0000000000 --- a/packages/client/connection/tests/files-server.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** The workspace-file listener's own failure and publication paths. */ -import { describe, expect, it } from 'vitest' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' -import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts' - -describe('workspace-file listener', () => { - it('answers 400 and reports the failure when the directory lookup throws', async () => { - const seen: Error[] = [] - const files = await listenForWorkspaceFiles( - '127.0.0.1', [], - { cwdFor: () => Promise.reject(new Error('store unavailable')) }, - (error) => { seen.push(error) }, - ) - try { - // A lookup failure is the host's problem, not a miss: it must not become - // an unhandled rejection, and it must not be reported as "not found". - const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`) - expect(response.status).toBe(400) - expect(seen.map(error => error.message)).toEqual(['store unavailable']) - } finally { - await files.close() - } - }) - - it('closes idempotently and stops answering', async () => { - const files = await listenForWorkspaceFiles( - '127.0.0.1', [], { cwdFor: async () => undefined }, () => {}, - ) - const origin = `http://127.0.0.1:${String(files.port)}` - expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404) - await files.close() - await files.close() - await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow() - }) -}) - -describe('injectFilesPort', () => { - it('publishes the port as the first script in head', () => { - const html = injectFilesPort('<html><head><title>x', 4321) - expect(html).toContain('') - // Ahead of anything the shell might read it from. - expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('')) - }) -}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 2561a0846f..216484ad67 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,9 +1,6 @@ -/** Node half: registers the /api and /f prefix routes over the api gateway and the session workspaces. */ +/** Node half: registers the /api prefix route bridging to the api gateway. */ import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' @@ -11,25 +8,17 @@ import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' import { API_PATH, apply, inject } from '../src/index.ts' /** Structural httpServer fake: the plugin only touches register(). */ -function fakeHttpServer( - routes: WebRoute[], - taps: ((html: string) => string)[] = [], -): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> { +function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> { return { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, - tapIndex(transform) { - taps.push(transform) - return () => { taps.splice(taps.indexOf(transform), 1) } - }, + tapIndex: () => () => {}, port: 0, - host: '127.0.0.1', } } @@ -60,47 +49,14 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -/** The gateway stub: only the session-directory authority the /f route reads. */ -function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy { - return { workspaceRootOf: async (id: string) => workspaces[id] } as unknown as ApiProxy -} - -async function mounted( - config?: { trustedHosts?: string[] }, - workspaces: Record<string, string> = {}, -): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> { +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> { const ctx = new Context() const routes: WebRoute[] = [] - const taps: ((html: string) => string)[] = [] - ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService) - ctx.provide('apiProxy', fakeApiProxy(workspaces)) + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, taps, dispose: () => fiber.dispose() } -} - -/** One raw GET whose Host header is spoofed (fetch forbids setting it). */ -function statusWithHost(origin: string, path: string, host: string): Promise<number> { - const url = new URL(origin) - return new Promise((resolve, reject) => { - const request = httpRequest( - { host: url.hostname, port: url.port, path, method: 'GET', headers: { host } }, - (response) => { - response.resume() - response.on('end', () => { resolve(response.statusCode ?? 0) }) - }, - ) - request.on('error', reject) - request.end() - }) -} - -/** The workspace-file origin the node half published into the index page. */ -function filesOrigin(taps: ((html: string) => string)[]): string { - const html = taps.reduce((acc, tap) => tap(acc), '<head></head>') - const port = /__DSH_FILES_PORT__ = (\d+)/.exec(html)?.[1] - if (port === undefined) throw new Error(`no workspace-file port was published: ${html}`) - return `http://127.0.0.1:${port}` + return { routes, dispose: () => fiber.dispose() } } describe('connection node half', () => { @@ -108,25 +64,17 @@ describe('connection node half', () => { const routes: WebRoute[] = [] const ctx = new Context() ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) - ctx.provide('apiProxy', fakeApiProxy()) + ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) }) - it('registers the /api route and publishes a separate workspace-file origin, both removed with the fiber', async () => { - const { routes, taps, dispose } = await mounted() - // The API keeps one prefix on the shared server; workspace files get a - // port of their own, which is the origin boundary between them. + it('registers the /api prefix route and removes it with the fiber', async () => { + const { routes, dispose } = await mounted() expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }]) - const origin = filesOrigin(taps) - expect(new URL(origin).port).not.toBe('') - expect((await fetch(`${origin}${FILES_PATH}/absent/x.txt`)).status).toBe(404) await dispose() expect(routes).toHaveLength(0) - expect(taps).toHaveLength(0) - // Disposal reaches quiescence: the socket is gone, not merely unrouted. - await expect(fetch(`${origin}${FILES_PATH}/absent/x.txt`)).rejects.toThrow() }) it('refuses an untrusted Host on any /api path before the bridge runs', async () => { @@ -187,48 +135,6 @@ describe('connection node half', () => { }) }) -describe('connection node half: the workspace-file origin', () => { - /** A workspace holding one file, torn down with the returned disposer. */ - async function workspace(): Promise<{ cwd: string; remove: () => Promise<void> }> { - const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-')) - await writeFile(join(cwd, 'index.html'), '<h1>ok</h1>') - return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) } - } - - it('applies the same browser-trust fence as /api, refuses writes, and serves nothing else', async () => { - const { taps, dispose } = await mounted() - const origin = filesOrigin(taps) - // Rebound Host: refused before any filesystem work, exactly as on /api. - // node's fetch refuses to set Host (a forbidden header), so the spoof goes - // through the raw client — the same parse the server really performs. - expect(await statusWithHost(origin, `${FILES_PATH}/s-1/index.html`, 'harness.example')).toBe(403) - const written = await fetch(`${origin}${FILES_PATH}/s-1/index.html`, { method: 'POST' }) - expect(written.status).toBe(405) - expect(written.headers.get('allow')).toBe('GET, HEAD') - // This origin is one route wide: no index, no SPA fallback, no API. - expect((await fetch(`${origin}/`)).status).toBe(404) - expect((await fetch(`${origin}${API_PATH}/session.list`, { method: 'POST' })).status).toBe(404) - await dispose() - }) - - it('confines reads to the directory the gateway names for that session', async () => { - const { cwd, remove } = await workspace() - const { taps, dispose } = await mounted(undefined, { 's-1': cwd }) - const origin = filesOrigin(taps) - const served = await fetch(`${origin}${FILES_PATH}/s-1/index.html`) - expect(served.status).toBe(200) - expect(await served.text()).toBe('<h1>ok</h1>') - // A served document keeps its own capabilities: the port is the boundary, - // so nothing here strips the document of its origin. - expect(served.headers.get('content-security-policy')).toBeNull() - // A session the gateway names no directory for has no workspace to confine - // against, so there is nothing to serve. - expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404) - await dispose() - await remove() - }) -}) - describe('connection node half over a real HTTP server', () => { /** Serve the registered prefix route from a real server and return its port. */ async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> { diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts deleted file mode 100644 index 8e33a6751b..0000000000 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Workspace-file reads over a real HTTP server and a real temporary - * workspace: confinement, content typing, and the sandbox header are wire - * facts, so they are asserted against responses Node actually produced. - */ -import { createServer } from 'node:http' -import type { AddressInfo } from 'node:net' -import type { ServerResponse } from 'node:http' -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, sep } from 'node:path' -import { Writable } from 'node:stream' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' -import { handleWorkspaceFile } from '../src/workspace-files.ts' - -const SESSION = 's-1' - -let workspace: string -let outside: string -let origin: string -let close: () => Promise<void> - -beforeAll(async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-files-')) - workspace = join(root, 'workspace') - outside = join(root, 'outside') - await mkdir(join(workspace, 'out'), { recursive: true }) - await mkdir(outside, { recursive: true }) - await writeFile(join(workspace, 'index.html'), '<h1>产物</h1>') - await writeFile(join(workspace, 'notes.txt'), 'plain') - await writeFile(join(workspace, 'chart.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>') - await writeFile(join(workspace, 'model.safetensors'), 'unknown extension') - await writeFile(join(workspace, 'out', 'page.html'), '<p>nested</p>') - await writeFile(join(outside, 'secret.html'), 'SECRET') - await symlink(join(outside, 'secret.html'), join(workspace, 'escape.html')) - - const server = createServer((req, res) => { - void handleWorkspaceFile(req, res, { - // 'rooted' names the filesystem root, the separator-terminated realpath case. - cwdFor: async sessionId => sessionId === SESSION ? workspace : sessionId === 'rooted' ? sep : undefined, - }) - }) - await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) - origin = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}` - close = () => new Promise<void>((resolve, reject) => { - server.close((error) => { - if (error === undefined || error === null) resolve() - else reject(error) - }) - }) - return async () => { await rm(root, { recursive: true, force: true }) } -}) - -afterAll(async () => { await close() }) - -function get(path: string, init?: RequestInit): Promise<Response> { - return fetch(`${origin}${path}`, init) -} - -describe('workspace file reads', () => { - it('serves a produced document with its own capabilities intact', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/index.html`) - expect(response.status).toBe(200) - expect(await response.text()).toBe('<h1>产物</h1>') - expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // No isolation header: the listener's own port is the origin boundary, so - // a preview keeps localStorage and cookies (see files-server). - expect(response.headers.get('content-security-policy')).toBeNull() - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - expect(response.headers.get('cache-control')).toBe('no-store') - expect(response.headers.get('content-disposition')).toBe('inline') - }) - - it('types SVG as a standalone document rather than sniffable bytes', async () => { - const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) - expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('x-content-type-options')).toBe('nosniff') - const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) - expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') - }) - - it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => { - // `realpath('/')` is '/', so a naive `root + sep` prefix is '//' and every - // child of that workspace would 403. - const rooted = await fetch(`${origin}${FILES_PATH}/rooted${new URL(`file://${workspace}/notes.txt`).pathname}`) - expect(rooted.status).toBe(200) - expect(await rooted.text()).toBe('plain') - }) - - it('shows an unknown extension as text rather than downloading it', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/model.safetensors`) - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8') - }) - - it('serves a nested path, so a document reaches its own siblings', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/out/page.html`) - expect(response.status).toBe(200) - expect(await response.text()).toBe('<p>nested</p>') - }) - - it('answers HEAD with the length and no body', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/notes.txt`, { method: 'HEAD' }) - expect(response.status).toBe(200) - expect(response.headers.get('content-length')).toBe('5') - expect(await response.text()).toBe('') - }) - - it('refuses a symlink whose target leaves the workspace', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/escape.html`) - expect(response.status).toBe(403) - expect(await response.text()).not.toContain('SECRET') - }) - - it('reports missing files, directories, and unknown sessions as absent', async () => { - expect((await get(`${FILES_PATH}/${SESSION}/nope.html`)).status).toBe(404) - expect((await get(`${FILES_PATH}/${SESSION}/out`)).status).toBe(404) - // A path whose ancestor is a file, not a directory. - expect((await get(`${FILES_PATH}/${SESSION}/notes.txt/child`)).status).toBe(404) - expect((await get(`${FILES_PATH}/s-other/index.html`)).status).toBe(404) - expect((await get(`${FILES_PATH}/${SESSION}`)).status).toBe(404) - }) -}) - -describe('workspace file streaming failures', () => { - it('tears the response down instead of rejecting when the body cannot be written', async () => { - // A client that goes away mid-stream must not surface as a handler - // rejection: the webserver's last-resort guard would log it and try to - // answer 400 on a response whose status line is already out. - const sink = new Writable({ - write(_chunk, _encoding, callback) { callback(new Error('socket gone')) }, - }) - const response = Object.assign(sink, { writeHead: () => response }) as unknown as ServerResponse - await expect(handleWorkspaceFile( - { url: `${FILES_PATH}/${SESSION}/index.html`, method: 'GET', headers: {} } as never, - response, - { cwdFor: async () => workspace }, - )).resolves.toBeUndefined() - expect(sink.destroyed).toBe(true) - }) -}) diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index a5827173a8..d389efe319 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,7 +26,6 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { api, - fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index a35983d890..fd7858d60c 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,7 +20,6 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, - fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index 6d7093a148..e892d9cd52 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -25,7 +25,6 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-web-react": "^0.0.1", @@ -36,7 +35,6 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", diff --git a/packages/client/test-runtime/src/connection.ts b/packages/client/test-runtime/src/connection.ts deleted file mode 100644 index 5df5d5a053..0000000000 --- a/packages/client/test-runtime/src/connection.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** Test-owned connection face: the transport members features read off `ctx.connection`. */ -import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { ConnectionHandle, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' - -/** - * Connection test double. Implements the same `ConnectionHandle` face features - * receive as `ctx.connection`, so a production face change breaks this double - * at compile time. The wire client is not modelled — a feature that needs one - * composes its own connection over a fake api client; this double exists for - * the transport facts features read synchronously, above all the - * workspace-file URL. - */ -export class TestConnection implements ConnectionHandle { - /** - * The workspace-file port the host would have published into the page. - * Unset — the default, and the keyless fixture lane's real state — makes - * {@link TestConnection.fileUrl} answer `undefined`, which is the signal a - * caller falls back to the Host opener on. - */ - filesPort: number | undefined - - /** The wire client; unused by this double's consumers and absent by construction. */ - readonly api: IApiClient = undefined as unknown as IApiClient - - /** - * Stream-loop starter (inert). - * @returns a stop handle that does nothing. - */ - start(): { stop(): void } { - return { stop: () => {} } - } - - /** - * Workspace-file URL, deriving exactly as production does so a feature test - * sees the real inside/outside-workspace split. - * @param sessionId - the Session whose cwd anchors the path. - * @param cwd - that Session's working directory. - * @param path - the path a tool reported. - * @returns the absolute URL on the workspace-file origin, or undefined when - * the path leaves the workspace or no port is published. - */ - fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined { - if (this.filesPort === undefined) return undefined - const segments = workspaceFileSegments(cwd, path) - if (segments === undefined) return undefined - return `http://localhost:${String(this.filesPort)}${workspaceFileUrl(sessionId, segments)}` - } -} diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index cdbdca75a2..5ef5350434 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -29,13 +29,11 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import { registerDomSnapshotSerializer } from './snapshot.ts' import { TestSessions } from './sessions.ts' -import { TestConnection } from './connection.ts' import { TestWorkspaces } from './workspaces.ts' import type { Stabilizer } from './fixtures.ts' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' -export { TestConnection } from './connection.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' @@ -177,8 +175,6 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces - /** The transport double features read as `ctx.connection`. */ - readonly connection: TestConnection private readonly stabilizer: Stabilizer = async (fn) => { await act(async () => { await fn() }) @@ -199,10 +195,8 @@ export class SlotTestRuntime { this.root = new TestRoot(slots, this.stabilizer) this.sessions = new TestSessions(this.stabilizer, ctx) this.workspaces = new TestWorkspaces(this.stabilizer) - this.connection = new TestConnection() ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) - ctx.provide('connection', this.connection) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). const renderer = createSlotRenderer() diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json index 681bff474c..6a758c66f9 100644 --- a/packages/client/test-runtime/tsconfig.json +++ b/packages/client/test-runtime/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../web-react" }, - { - "path": "../connection" - }, { "path": "../runtime" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 446c2084b1..e85aa2ca30 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: 8c2075d615eccad1bbc7f5de1255ea4add69fab8 -README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d +README.md: a9d4aadf4b0acc21f3909319724645c10f08bd31 +README.zh.md: 16be57c9ed8f8101a61eb704ba5e94491803d9b5 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 8c2075d615..a9d4aadf4b 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 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)). -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab on the transport's workspace-file origin (`ConnectionHandle.fileUrl`), so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is an underlined link — it reads as one at rest, not only on hover, because a path styled like the surrounding prose is an affordance nobody finds — and it opens the file through the Host (`host.openPath`, relative paths resolve against the session cwd). A document a browser renders opens in the default browser rather than the type's default application, so a produced page is shown rather than edited. The Host opens it on the Host's own machine: a client reached over a network sees nothing, which is the deliberate scope of this surface. Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 634721b424..16be57c9ed 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`ConnectionHandle.fileUrl`),因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是带下划线的链接——静止状态下就读得出是链接,而不只在悬停时,因为一条与周围正文同样样式的路径是没人会发现的交互——点击即经由 Host 打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。浏览器能渲染的文档会用默认浏览器打开,而不是该类型的默认应用,因此产出的页面是被展示而不是被编辑。Host 在它自己的机器上打开:经网络访问的客户端看不到任何东西,这是本交互面刻意划定的范围。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 55036600c6..88c09b5550 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -39,7 +39,6 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -51,7 +50,6 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a65ce1d7a4..c67431e409 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,7 +2,6 @@ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -43,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -276,16 +275,6 @@ export function apply(ctx: Context): void { }, openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - // A file inside the workspace opens in a new tab on the transport's - // workspace-file origin, so a browser that is not on the Host machine - // can still see what the agent produced. Anything outside it has no - // served URL and falls back to the Host's own opener, which is - // loopback-only by the /api trust fence. - const url = (ctx.get('connection') as ConnectionHandle).fileUrl(sessionId, cwd, path) - if (url !== undefined) { - window.open(url, '_blank', 'noopener,noreferrer') - return - } void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { // Host/OS open failures stay silent in the chat row; the native // app surfaces its own error dialog when the path is unusable. diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 81e41b066f..3e4fda9ebe 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -84,7 +84,10 @@ color: var(--dsw-alias-label-tertiary); } -/* File-tool path: same geometry as .summary; hover underline + pointer. */ +/* File-tool path: same geometry as .summary, but it must READ as a link. A + path styled exactly like the surrounding prose, underlined only on hover, is + an affordance nobody finds — the reported "I can't open what it made" was + this, not a missing capability. */ .fileLink { flex: 1 1 auto; min-width: 0; @@ -99,12 +102,16 @@ text-align: left; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); + text-decoration: underline; + text-decoration-color: var(--dsw-alias-label-quaternary); + text-underline-offset: 3px; cursor: pointer; } .fileLink:hover { - text-decoration: underline; + color: var(--dsw-alias-label-primary); + text-decoration-color: currentColor; } /* Error row's collapsed summary: the failure's first line in the error color. */ diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index b9dbe0d6ad..cfbb0fdcbe 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -218,25 +218,13 @@ describe('conversation slot inject surface', () => { await b.runtime.dispose() }) - it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => { + it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { const b = await bench() - // A host that publishes a workspace-file port: previews come from that - // origin, which is what keeps them off the API's. - b.runtime.connection.filesPort = 4321 - const open = vi.spyOn(window, 'open').mockReturnValue(null) const { injected } = b.chatViewSurface(ROOT) - // Inside the session cwd: served on the workspace-file origin, so a browser - // anywhere on the network sees the file the agent produced. injected.openFile('src/a.ts') - expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer') - expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false) - // Outside it there is no served URL, so the Host's own opener answers — - // resolved against the session cwd exactly as before. - injected.openFile('/etc/hosts') await vi.waitFor(() => { - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/etc/hosts'] }) + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) }) - open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 2763702c0b..bafc6fe709 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -136,9 +136,6 @@ async function bench(snapshot: ConversationSnapshot) { openPath: vi.fn(async () => {}), } ctx.provide('workspaces', workspaces) - // The transport face the chat view reads its workspace-file URLs from. - const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) } - ctx.provide('connection', connection) ctx.provide('layout', layout) const locale = new LocaleService(ctx) ctx.provide('locale', locale) @@ -246,14 +243,12 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ]]]) const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) - const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = mountApp(b.slots) view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') + expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt') }) - open.mockRestore() view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 84cdd53eeb..eb48677d4f 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -119,17 +119,14 @@ describe('keyed toolview hole through the real machinery', () => { await b.runtime.dispose() }) - it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => { + it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => { const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')]) - b.runtime.connection.filesPort = 4321 - const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = b.runtime.renderRoot() view.getByText('src/a.ts').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(open).toHaveBeenCalledWith(expect.stringContaining('/src/a.ts'), '_blank', 'noopener,noreferrer') + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] }) }) - open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 33d45124b4..04b265bdd5 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../web-react" }, - { - "path": "../connection" - }, { "path": "../runtime" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index b96bf528a7..27a1434e60 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: ee8e758a68f6efa3e363a36fcc9e8444e589ea40 -README.zh.md: 4ec3817e65543d6e248be9d902d0b74674f56e5a +README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5 +README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ee8e758a68..3c5a83a468 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,8 +36,6 @@ The `command.*` and `skill.*` domains expose the host command registry and skill The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. -Two members of `ApiProxy` are deliberately not wire methods. `respond` is the client-response entry (four-quadrant model), and `workspaceRootOf` answers where a Session's files live for an in-process reader — a live agent's header first, then the persistence store, never a resume. It has no wire face: a browser learns a Session's cwd from `sessions.view`, and reaches a file through the web transport's own `/f` route, never by asking for a host path. That route's URL shape (`api/files.ts`: `FILES_PATH`, `workspaceFileSegments`, `workspaceFileUrl`, `parseWorkspaceFilePath`) lives here with the other browser-importable contract surfaces, so the browser half that builds a `/f` URL and the serving half that parses one cannot drift apart; the route itself belongs to [`dsh-client-connection`](../../client/connection/README.md). - ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4ec3817e65..f853356457 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,8 +36,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 -`ApiProxy` 上有两个成员刻意不是协议方法。`respond` 是客户端响应入口(四象限模型),`workspaceRootOf` 则为进程内读取方回答某个 Session 的文件位于何处——先看活跃 agent 的 header,再看持久化存储,绝不恢复会话。它没有协议面:浏览器从 `sessions.view` 得知 Session 的 cwd,并经由 web 传输自己的 `/f` 路由抵达文件,而不是靠索要一条宿主路径。该路由的 URL 形状(`api/files.ts`:`FILES_PATH`、`workspaceFileSegments`、`workspaceFileUrl`、`parseWorkspaceFilePath`)与其余浏览器可导入的契约面一同放在这里,因此构造 `/f` URL 的浏览器半侧与解析它的服务半侧不会彼此漂移;路由本身则属于 [`dsh-client-connection`](../../client/connection/README.md)。 - ## 载体层(`/client` + 根路径) `AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 63e1f0007c..4e506ed262 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2290,20 +2290,5 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro pending.resolve(payload.answer) return Promise.resolve({ accepted: true }) }, - - async workspaceRootOf(sessionId: SessionId): Promise<string | undefined> { - // A live agent answers from its own header; otherwise the store answers, - // deliberately without resuming — reading a session's directory must not - // pull an agent up the way the cold RPC path does. - const live = ctx.agents.get(sessionId) - if (live !== undefined) return live.session.header.cwd - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) return undefined - // TODO(persistence/by-id): a full listing per lookup. Harmless while the - // caller is one preview open, but a served document with N relative - // sub-resources pays it N times; a by-id header read on the persistence - // seam would retire it. - return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd - }, } } diff --git a/packages/host/apiproxy/src/api/files.ts b/packages/host/apiproxy/src/api/files.ts deleted file mode 100644 index b4ba01f29b..0000000000 --- a/packages/host/apiproxy/src/api/files.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * The `/f` workspace-file URL shape: the contract half of the web transport - * that carries bytes rather than RPC. The browser turns a tool's file path - * into a URL, the serving side turns that URL back into the segments below a - * session's cwd, and both read this one encoding decision so neither can drift - * into serving a path the other never meant. Pure string work with no Node and - * no DOM, like the rest of `api/` — the browser bundle inlines it. - * @module @deepseek-ai/dsh-host-apiproxy/api/files - */ - -/** - * Route prefix owning every workspace-file read (`/f/<sessionId>/<segments…>`). - * The path carries the segments verbatim rather than a query parameter so a - * served document's relative references (`./logo.png`) resolve to their - * siblings in the same workspace directory. - */ -export const FILES_PATH = '/f' - -/** One parsed workspace-file request: whose workspace, and where inside it. */ -export interface WorkspaceFileTarget { - /** The owning session, still an opaque string — the caller resolves it to a cwd. */ - sessionId: string - /** Decoded path segments below that session's cwd; never empty, never `.` or `..`. */ - segments: string[] -} - -/** A segment that survived decoding but would re-enter path resolution as more than one name. */ -function isPlainSegment(segment: string): boolean { - return segment !== '' && segment !== '.' && segment !== '..' - && !segment.includes('/') && !segment.includes('\\') && !segment.includes('\0') -} - -function decode(raw: string): string | undefined { - try { - return decodeURIComponent(raw) - } catch { - // A malformed %-escape is a request we cannot interpret, not a miss. - return undefined - } -} - -/** - * Express one tool-reported file path as segments below the session cwd. - * @param cwd - the session's working directory, or `undefined` when unknown. - * @param path - the path the tool reported (absolute, or relative to `cwd`). - * @returns the segments below `cwd`, or `undefined` when the path names - * something outside the workspace (which this route never serves) or resolves - * to the workspace directory itself. - */ -export function workspaceFileSegments(cwd: string | undefined, path: string): string[] | undefined { - const slashed = path.replace(/\\/g, '/') - const absolute = /^\/|^[A-Za-z]:\//.test(slashed) - let relative: string - if (absolute) { - if (cwd === undefined || cwd === '') return undefined - const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '') - if (!slashed.startsWith(`${root}/`)) return undefined - relative = slashed.slice(root.length + 1) - } else { - relative = slashed - } - const segments = relative.split('/').filter(segment => segment !== '' && segment !== '.') - if (segments.length === 0 || segments.some(segment => !isPlainSegment(segment))) return undefined - return segments -} - -/** - * Build the origin-relative URL serving one workspace file. - * @param sessionId - the session whose cwd anchors the path. - * @param segments - segments below that cwd, as {@link workspaceFileSegments} returns them. - * @returns the `/f/…` URL, resolved by the browser against the serving origin. - */ -export function workspaceFileUrl(sessionId: string, segments: readonly string[]): string { - const encoded = segments.map(segment => encodeURIComponent(segment)).join('/') - return `${FILES_PATH}/${encodeURIComponent(sessionId)}/${encoded}` -} - -/** - * Parse a request pathname back into the session and segments it names. - * @param pathname - the request's raw (still percent-encoded) pathname. - * @returns the target, or `undefined` when the pathname is not a well-formed - * workspace-file read — including every traversal shape, which is refused here - * before any filesystem call rather than being resolved and then judged. - */ -export function parseWorkspaceFilePath(pathname: string): WorkspaceFileTarget | undefined { - if (!pathname.startsWith(`${FILES_PATH}/`)) return undefined - const [rawSession, ...rawSegments] = pathname.slice(FILES_PATH.length + 1).split('/') - if (rawSession === undefined || rawSegments.length === 0) return undefined - const sessionId = decode(rawSession) - if (sessionId === undefined || sessionId === '') return undefined - const segments: string[] = [] - for (const raw of rawSegments) { - const segment = decode(raw) - if (segment === undefined || !isPlainSegment(segment)) return undefined - segments.push(segment) - } - return { sessionId, segments } -} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 227e26264e..c97cd33e1b 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -15,9 +15,6 @@ import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' -// The merge-free types subpath: api/ is imported from the browser lane, where -// the host session service must not merge over the client runtime's own. -import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { @@ -33,17 +30,6 @@ export interface ApiProxy { llm: LlmApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise<RpcReceipt> - /** - * The directory a Session's files may be read from — the same `cwd` the - * session summaries carry, in non-envelope form for an in-process reader. - * Not a domain method: it has no wire face, because a browser learns a - * Session's cwd from `sessions.view` and a file it may read from the web - * transport's own `/f` route, never by asking for a host path. - * @param sessionId - the Session to locate. - * @returns its absolute working directory, or `undefined` when this host - * serves no such Session. Resolving one never resumes an agent. - */ - workspaceRootOf(sessionId: SessionId): Promise<string | undefined> } // ---- Domain interfaces and payload entities ---- @@ -63,9 +49,6 @@ export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, LlmApi } from './llm.ts' export type { ApprovalResponsePayload } from './approvals.ts' -// ---- Workspace-file URL shape (the transport's byte-carrying half) ---- -export { FILES_PATH, workspaceFileSegments, workspaceFileUrl, parseWorkspaceFilePath } from './files.ts' -export type { WorkspaceFileTarget } from './files.ts' export type { QuestionResponsePayload } from './questions.ts' // ---- Message layer: narrow forms (domain-signature view) ---- diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index f6dec19420..339b1e777d 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -64,7 +64,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] - readonly workspaceRootOf: ApiProxy['workspaceRootOf'] constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') @@ -88,7 +87,6 @@ export class ApiProxyService extends Service implements ApiProxy { // createApiProxy returns closures (no `this` capture); bind only satisfies // the unbound-method lint without changing behavior. this.respond = api.respond.bind(api) - this.workspaceRootOf = api.workspaceRootOf.bind(api) } } diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index a4fbbaa72e..444cf3d408 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,5 +1,16 @@ -/** Cross-platform open-with-default-application used by the local GUI carrier. */ +/** + * Cross-platform open-with-default-application used by the local GUI carrier. + * + * A document a browser RENDERS is opened with the user's default browser + * rather than the default application for its type, when the platform can name + * one: a developer who binds `.html` to an editor would otherwise click a + * produced page and get source code. The contract is uniform — prefer the + * default browser, fall back to the default application — while how completely + * a platform can answer "which browser" differs, and every failure falls back + * rather than surfacing. + */ +import { extname } from 'node:path' import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ @@ -9,6 +20,60 @@ export type PathOpenerRunner = NativeCommandRunner export interface PathOpenerInternals { platform?: NodeJS.Platform run?: PathOpenerRunner + /** Environment the linux browser convention reads; defaults to the process env. */ + env?: NodeJS.ProcessEnv +} + +/** Documents a browser renders, as opposed to ones an editor merely edits. */ +const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg']) + +/** + * The macOS bundle registered for `https` — the default browser, as + * LaunchServices records it. The nested version dict is stripped first + * because it carries its own `LSHandlerRoleAll`. + */ +function macBundleForHttps(plist: string): string | undefined { + const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '') + const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0] + if (block === undefined) return undefined + return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1] +} + +/** + * Open one browser-renderable document with the default browser. + * @returns true when a browser took it; false when this platform cannot name + * one, or naming it failed — the caller then uses the default application. + */ +async function openInBrowser( + path: string, signal: AbortSignal, platform: NodeJS.Platform, + run: PathOpenerRunner, env: NodeJS.ProcessEnv, +): Promise<boolean> { + if (platform === 'darwin') { + let bundle: string | undefined + try { + const { stdout } = await run( + 'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal) + bundle = macBundleForHttps(stdout) + } catch { + // No LaunchServices record (a fresh account never changed a default): + // the content-type handler is then the system's own choice anyway. + return false + } + if (bundle === undefined) return false + await run('open', ['-b', bundle, path], signal) + return true + } + if (platform === 'linux') { + // $BROWSER is the portable convention; desktop-entry resolution through + // xdg-settings needs a launcher this package has no business shipping. + const browser = env.BROWSER + if (browser === undefined || browser === '') return false + await run(browser, [path], signal) + return true + } + // Windows names no browser without reading the UserChoice registry, and its + // .html association is the browser in the ordinary case. + return false } /** PowerShell single-quoted literal (doubles embedded quotes). */ @@ -17,10 +82,11 @@ function powershellLiteral(path: string): string { } /** - * Open a filesystem path with the operating system's default application. + * Open a filesystem path with the operating system's default application, or + * with the default browser when the path names a document a browser renders. * @param path - absolute or host-resolvable path (caller owns resolution). * @param signal - caller/connection lifetime; abort terminates the native command. - * @param internals - platform and runner seam for deterministic tests. + * @param internals - platform, environment, and runner seam for deterministic tests. */ export async function openNativePath( path: string, @@ -29,6 +95,10 @@ export async function openNativePath( ): Promise<void> { const platform = internals.platform ?? process.platform const run = internals.run ?? runNativeCommand + const env = internals.env ?? process.env + + if (BROWSER_DOCUMENTS.has(extname(path).toLowerCase()) + && await openInBrowser(path, signal, platform, run, env)) return if (platform === 'darwin') { await run('open', [path], signal) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index cc30e5dee2..da05a4cd9b 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -62,11 +62,7 @@ function stubAgent(session: Session): Agent { async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, - extras: { - openPath?: (path: string, signal: AbortSignal) => Promise<void> - /** Store contents behind the gateway, or 'absent' for a composition with no persistence at all. */ - persisted?: { id: SessionId; cwd?: string }[] | 'absent' - } = {}, + extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {}, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -77,10 +73,7 @@ async function harness( const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) ctx.storage.mount('domain', storageDomain) ctx.provide('storageDomain', storageDomain) - if (extras.persisted !== 'absent') { - const persisted = extras.persisted ?? [] - ctx.provide('sessionPersistence', { list: () => Promise.resolve(persisted) } as never) - } + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) await ctx.plugin(WorkspaceRegistry) const factory: AgentFactory = { @@ -251,27 +244,6 @@ describe('host.openPath', () => { }) }) -describe('workspaceRootOf', () => { - it('answers from the live agent, then the store, and names nothing for an unknown session', async () => { - const { api, workspaceRoot } = await harness(undefined, undefined, { - persisted: [{ id: 's-cold' as SessionId, cwd: '/w/cold' }], - }) - const created = await api.sessions.create(request({ cwd: workspaceRoot })) - const sessionId = (created.result as { ok: true; value: { sessionId: SessionId } }).value.sessionId - // Live: the agent's own header, no store read involved. - await expect(api.workspaceRootOf(sessionId)).resolves.toBe(workspaceRoot) - // Not live: the store answers, and the lookup never resumes an agent — - // this harness's factory throws on resume, so a resuming lookup would fail. - await expect(api.workspaceRootOf('s-cold' as SessionId)).resolves.toBe('/w/cold') - await expect(api.workspaceRootOf('s-absent' as SessionId)).resolves.toBeUndefined() - }) - - it('names nothing at all when the host keeps no session store', async () => { - const { api } = await harness(undefined, undefined, { persisted: 'absent' }) - await expect(api.workspaceRootOf('s-any' as SessionId)).resolves.toBeUndefined() - }) -}) - describe('workspace.create', () => { it('serializes concurrent names and rejects the duplicate', async () => { const { api, workspaceRoot } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2299949890..6307dfe8f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -108,8 +108,6 @@ function scriptedApi(overrides: { }, events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), - // No wire face, so the handler map never reaches it. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index dac49a1234..ef111afe12 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -233,8 +233,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise<RpcReceipt> { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, - // No wire face, so the carrier never reaches it. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/files-path.spec.ts b/packages/host/apiproxy/tests/files-path.spec.ts deleted file mode 100644 index df309a4783..0000000000 --- a/packages/host/apiproxy/tests/files-path.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** The /f URL shape: one encoding decision, asserted from both ends. */ -import { describe, expect, it } from 'vitest' -import { - FILES_PATH, parseWorkspaceFilePath, workspaceFileSegments, workspaceFileUrl, -} from '../src/api/files.ts' - -describe('workspaceFileSegments', () => { - it('keeps a relative path as its own segments', () => { - expect(workspaceFileSegments('/w', 'out/index.html')).toEqual(['out', 'index.html']) - expect(workspaceFileSegments(undefined, 'index.html')).toEqual(['index.html']) - expect(workspaceFileSegments('/w', './a/./b.txt')).toEqual(['a', 'b.txt']) - }) - - it('strips the cwd prefix from an absolute path inside the workspace', () => { - expect(workspaceFileSegments('/w', '/w/a/b.html')).toEqual(['a', 'b.html']) - // A trailing separator on the cwd must not shift the split. - expect(workspaceFileSegments('/w/', '/w/a.html')).toEqual(['a.html']) - }) - - it('reads Windows paths on either separator', () => { - expect(workspaceFileSegments('C:\\w', 'C:\\w\\a\\b.html')).toEqual(['a', 'b.html']) - expect(workspaceFileSegments('C:/w', 'C:\\w\\a.html')).toEqual(['a.html']) - }) - - it('refuses everything the route would not serve', () => { - // Absolute, but not under this workspace. - expect(workspaceFileSegments('/w', '/etc/hosts')).toBeUndefined() - // A sibling directory sharing the cwd's name prefix is not inside it. - expect(workspaceFileSegments('/w', '/workspace-other/a')).toBeUndefined() - // Absolute with no cwd to anchor against. - expect(workspaceFileSegments(undefined, '/w/a.html')).toBeUndefined() - expect(workspaceFileSegments('', '/w/a.html')).toBeUndefined() - // Traversal, in either spelling. - expect(workspaceFileSegments('/w', '../secret')).toBeUndefined() - expect(workspaceFileSegments('/w', 'a/../../secret')).toBeUndefined() - // The workspace directory itself is not a file. - expect(workspaceFileSegments('/w', '/w')).toBeUndefined() - expect(workspaceFileSegments('/w', '.')).toBeUndefined() - }) -}) - -describe('workspaceFileUrl', () => { - it('percent-encodes each segment but keeps the separators structural', () => { - expect(workspaceFileUrl('s-1', ['out', 'a b.html'])).toBe(`${FILES_PATH}/s-1/out/a%20b.html`) - expect(workspaceFileUrl('s/1', ['a#b.html'])).toBe(`${FILES_PATH}/s%2F1/a%23b.html`) - }) -}) - -describe('parseWorkspaceFilePath', () => { - it('round-trips what the browser half builds', () => { - const url = workspaceFileUrl('s-1', ['out', 'a b.html']) - expect(parseWorkspaceFilePath(url)).toEqual({ sessionId: 's-1', segments: ['out', 'a b.html'] }) - }) - - it('refuses malformed, prefix-foreign, and traversal pathnames', () => { - expect(parseWorkspaceFilePath('/api/session.list')).toBeUndefined() - expect(parseWorkspaceFilePath(FILES_PATH)).toBeUndefined() - // Session named but no file below it. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}//a.html`)).toBeUndefined() - // Traversal is refused at parse time, before any filesystem call. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/../etc/hosts`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a/./b`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a//b`)).toBeUndefined() - // A separator smuggled through percent-encoding stays one segment's problem. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%2F..%2Fb`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%5Cb`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%00b`)).toBeUndefined() - // Malformed percent-escapes are uninterpretable, not a miss to resolve. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%zz`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/%zz/a.html`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}//`)).toBeUndefined() - }) -}) diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index c8622002e2..f56ff1c66b 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -80,3 +80,96 @@ describe('native path opener', () => { }) }) }) + +describe('browser-renderable documents', () => { + const LS_PLIST = `{ + LSHandlers = ( + { + LSHandlerPreferredVersions = { + LSHandlerRoleAll = "-"; + }; + LSHandlerRoleAll = "com.google.chrome"; + LSHandlerURLScheme = https; + } + ); +}` + + it('opens a page with the default browser rather than the .html handler on darwin', async () => { + const calls: { command: string; args: readonly string[] }[] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push({ command, args }) + return { stdout: command === 'defaults' ? LS_PLIST : '', stderr: '' } + } + await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run }) + // A developer who bound .html to an editor still gets a rendered page. + expect(calls.map(c => [c.command, ...c.args])).toEqual([ + ['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], + ['open', '-b', 'com.google.chrome', '/w/page.html'], + ]) + }) + + it('leaves every other document to the default application', async () => { + const calls: string[][] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push([command, ...args]) + return { stdout: '', stderr: '' } + } + await openNativePath('/w/report.md', new AbortController().signal, { platform: 'darwin', run }) + // No LaunchServices read at all: markdown is not a browser document. + expect(calls).toEqual([['open', '/w/report.md']]) + }) + + it('falls back to the default application when no browser can be named', async () => { + // LaunchServices has no https record (a fresh account), so the system's + // own content-type choice is the best answer available. + const calls: string[][] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push([command, ...args]) + if (command === 'defaults') throw new Error('domain not found') + return { stdout: '', stderr: '' } + } + await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run }) + expect(calls).toEqual([ + ['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], + ['open', '/w/page.html'], + ]) + + // A record without an https handler is the same answer. + const bare: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'darwin', + run: async (command, args) => { + bare.push([command, ...args]) + return { stdout: '{ LSHandlers = ( ); }', stderr: '' } + }, + }) + expect(bare[1]).toEqual(['open', '/w/page.html']) + }) + + it('honors $BROWSER on linux and leaves windows to its association', async () => { + const linux: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'linux', + env: { BROWSER: 'firefox' }, + run: async (command, args) => { linux.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(linux).toEqual([['firefox', '/w/page.html']]) + + // Unset $BROWSER: xdg-open's association is the fallback. + const bare: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'linux', + env: {}, + run: async (command, args) => { bare.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(bare).toEqual([['xdg-open', '/w/page.html']]) + + // Windows names no browser without the UserChoice registry. + const win: string[][] = [] + await openNativePath('C:\\w\\page.html', new AbortController().signal, { + platform: 'win32', + run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(win[0]?.[0]).toBe('powershell.exe') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fb7df52ad..7073f04e4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1183,9 +1183,6 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1266,9 +1263,6 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/tsconfig.host.json b/tsconfig.host.json index 9de5b51da0..89f57e364a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -38,7 +38,7 @@ "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", - "apps/web/tests/workspace-file-open.e2e.ts", + "apps/web/tests/produced-files.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", From 59affddfc5cc5b7e70b864031eb7d647d832d883 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 13:55:28 +0800 Subject: [PATCH 034/190] docs(tools): align Code Mode docs with multi-language dispatch; py-types notes Address ds-review-bot v5/v6 review round 3: - Config.mode JSDoc and the regenerated config-catalog no longer claim Code Mode requires a TypeScript runtime; both now say a language with a registered SDK renderer. - The active 2026-06-15-code-mode base note (both languages) follows shipped reality: the SDK renders the loaded runtime's language, dsh-tools accepts any language with a renderer and run_code flavor, and it cross-links the language-dispatch note. - The language-dispatch note distinguishes the two Object.hasOwn guards' reachability and documents the peekRuntime no-runtime degrade vs the rejected silent fallback. - SDK_RENDERERS comment: adding a language is two table entries, not one. - py-types: document the deliberate PEP 586 deviation for float Literals; add oneOf-object-branch tests (named union classes and context-free degrade), keeping py-types.ts at 100% per-file coverage. --- .../feature/2026-06-15-code-mode.i18n.yaml | 4 +-- .../feature/2026-06-15-code-mode.md | 6 ++-- .../feature/2026-06-15-code-mode.zh.md | 6 ++-- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +-- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- docs/config-catalog.md | 7 ++-- packages/core/tools/src/index.ts | 9 +++-- packages/core/tools/src/py-types.ts | 9 ++++- packages/core/tools/tests/py-types.spec.ts | 34 +++++++++++++++++++ 10 files changed, 64 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 8773a797e9..c6fe62db5d 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-code-mode.md -2026-06-15-code-mode.md: b6a24ecd9700e32912b8112b59cbd8b6ab131eb5 -2026-06-15-code-mode.zh.md: 4d0a4cf8fa31cf9d9954e5bd95f823dfc0668444 +2026-06-15-code-mode.md: 31b39842bb20135517f41ced3f586d61454023e3 +2026-06-15-code-mode.zh.md: 88bade054928d4a2a76316825a49109fae104eb7 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md index b6a24ecd97..31b39842bb 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md @@ -32,7 +32,7 @@ This note owns Code Mode's presentation, composition, isolation, and settlement **Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it. -**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders TypeScript declarations plus fixed usage instructions for the scope's visible capabilities. It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. +**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md) added Python and the `ctx.codeRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. **Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition. @@ -64,7 +64,7 @@ Each sub-dispatch appends a log-only `tool/code-dispatch-start` event at pool en - `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<CodeJsonValue>>; errorClass?: { name: string; memberNameProperty: string } }` — the runtime exposes each namespace as a global object of async functions inside the program; the optional descriptor asks the runtime to inject a real program-visible rejection class without teaching the seam consumer-specific names. `CodeJsonValue` is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole. - `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }` — program execution outcomes resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary. - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout, a lossy completion is not an overflow, and a substrate exit is none of them. -- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). +- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the first backend; a Python backend says `'python'` and pairs with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` accepts any `language` with a registered SDK renderer and `run_code` flavor (TypeScript and Python ship; see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)) and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator. @@ -85,7 +85,7 @@ The worker runtime provides containment, not a security boundary: model code can ### What the model sees -The SDK instructs the model to write an async erasable-TypeScript body, call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under `Promise.all`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. +The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under `Promise.all`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index 4d0a4cf8fa..88bade0549 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -32,7 +32,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 **与 `toolOrder` 的交互,预先说明:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。 -**SDK 提示词段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 +**SDK 提示词段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](2026-07-31-code-mode-language-dispatch.md) 加入了 Python 与按 `ctx.codeRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 **组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 @@ -64,7 +64,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 - `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<CodeJsonValue>>; errorClass?: { name: string; memberNameProperty: string } }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;可选描述符要求运行时注入真正的、程序可见的 reject 类,而无需让 seam 获知消费方专用名称。`CodeJsonValue` 是这个低依赖 seam 的结构化无损 JSON 类型,因此绑定参数与解析值可以完整跨越实现的序列化边界。 - `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }`——程序执行失败时,执行 promise 仍会 fulfill,并通过 `error` 字段返回失败结果。只有调用方/seam 误用(例如重复的绑定命名空间)时,`run()` 才会 reject;消费方仍在自己的错误边界处理不合规后端的拒绝。 - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时,有损完成值不是溢出,基底退出也与上述情况相互独立。 -- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——交付的后端为 `'typescript'`;Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。 +- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——首个后端为 `'typescript'`;Python 后端声明 `'python'`,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 接受任何注册了 SDK 渲染器与 `run_code` flavor 的 `language`(TypeScript 与 Python 已交付;见[语言分发 note](2026-07-31-code-mode-language-dispatch.md)),否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。 请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。 @@ -85,7 +85,7 @@ worker 运行时提供的是隔离,而非安全边界:模型代码可以访 ### 模型看到的内容 -SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 +SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-code-mode-language-dispatch.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 7160013fb7..6d43a50a7b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 55af3c7b71c7fc55d5140edb86494f2ca83d41c4 -2026-07-31-code-mode-language-dispatch.zh.md: 3a2eb78ec48f4479e2eb82a6a1e4f351a36c8bd3 +2026-07-31-code-mode-language-dispatch.md: c5643485f5ff9beda8d3f057379242fb4bcc7407 +2026-07-31-code-mode-language-dispatch.zh.md: 889168698215560da1d15799f814d21cff25acf7 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 55af3c7b71..c5643485f5 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -17,7 +17,7 @@ Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at - `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. - `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. -Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer; a language present on neither table but reaching the read fails loud (defense-in-depth against a caller bypassing the guard). Adding a backend language is two table entries plus its renderer — no `agent-loop` or registry-structure change. +Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — reading `ctx.tools.schemas()` under a runtime whose language has a renderer but no flavor entry hits it, and a test covers it. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is two table entries plus its renderer — no `agent-loop` or registry-structure change. `code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 3a2eb78ec4..8891686982 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器;一个两张表都没有、却仍走到读取处的语言会 fail loud(对绕过守卫的调用方的纵深防御)。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——在语言有渲染器却无 flavor 表项的运行时下读 `ctx.tools.schemas()` 即到达,且有测试覆盖。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 95bd923c29..f266b871fd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2021,8 +2021,9 @@ export interface Config { /** * Model presentation. `native` (default) sends every visible schema; `code` * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a TypeScript runtime and fail prompt assembly when it is - * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. + * Code modes require a `ctx.codeRuntime` whose `language` has a registered + * SDK renderer (TypeScript or Python) and fail prompt assembly when it is + * absent or has no renderer. Under `code`, native names in `toolOrder` are invalid. */ mode?: ToolPresentationMode /** @@ -2039,7 +2040,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:603`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:605`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 22569faa6c..a5851d154e 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -31,7 +31,9 @@ import { renderToolsSdkPy } from './py-types.ts' * `ctx.codeRuntime.language` in this table when assembling the `tools:sdk` * section under a non-native mode; a runtime whose language is not a key * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a - * new backend language is a table entry plus its renderer, nothing else. + * new backend language is two table entries — a renderer here and a + * {@link RUN_CODE_FLAVORS} entry for its `run_code` schema strings — plus the + * renderer itself. */ const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = { typescript: renderToolsSdk, @@ -604,8 +606,9 @@ export interface Config { /** * Model presentation. `native` (default) sends every visible schema; `code` * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a TypeScript runtime and fail prompt assembly when it is - * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. + * Code modes require a `ctx.codeRuntime` whose `language` has a registered + * SDK renderer (TypeScript or Python) and fail prompt assembly when it is + * absent or has no renderer. Under `code`, native names in `toolOrder` are invalid. */ mode?: ToolPresentationMode /** diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 49a01b5452..697df8ae8d 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -154,7 +154,14 @@ function pyScalar(value: JsonSchemaScalar): string { return String(value) } -/** Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to the broad type. */ +/** + * Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to + * the broad type. Deliberately deviates from PEP 586, which restricts `Literal` + * parameters to int/bool/str/bytes/enum/None: a number `const`/`enum` emits a + * float literal (`Literal[1.5]`) a strict checker would reject. Harmless here — + * the stub is advisory prompt text, only required to parse — and keeping the + * exact value communicates the constraint to the model. + */ function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string { if (Object.hasOwn(node, 'const')) { state.typing.add('Literal') diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index b9a244594d..80ae2c2084 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -217,6 +217,40 @@ describe('renderToolsSdkPy', () => { expect(text.indexOf('class WorkflowArgs(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):')) }) + it('renders a oneOf of object branches as a union of named TypedDicts declared before the parent', () => { + const tool: ToolSdkSchema = { + name: 'act', + description: 'Union output.', + parameters: { type: 'object', additionalProperties: false, properties: {} }, + output: { + oneOf: [ + { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] }, + { type: 'object', additionalProperties: false, properties: { err: { type: 'string' } }, required: ['err'] }, + ], + }, + } + const text = renderToolsSdkPy([tool]) + // Each object branch becomes its own named class (`${base}Output1/2`), + // declared before the protocol references the union. + expect(text).toContain('class ActOutput1(TypedDict):') + expect(text).toContain('class ActOutput2(TypedDict):') + expect(text).toContain('-> ActOutput1 | ActOutput2') + expect(text.indexOf('class ActOutput1(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):')) + expect(text.indexOf('class ActOutput2(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):')) + }) + + it('degrades a context-free oneOf of object branches to a union of dict[str, Any]', () => { + // jsonSchemaToPy has no naming context, so each object branch degrades + // rather than declaring a class. + const type = jsonSchemaToPy({ + oneOf: [ + { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] }, + { type: 'string' }, + ], + }) + expect(type).toBe('dict[str, Any] | str') + }) + it('suffixes a counter when two tools CamelCase to the same class base', () => { const a: ToolSdkSchema = { name: 'my-tool', From 26a94b56f6383fbc812e549d0139837133f79c65 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 13:58:20 +0800 Subject: [PATCH 035/190] docs(tools): regenerate cordis catalog and event graph for shifted lines The index.ts JSDoc edits shifted source line numbers referenced by the generated cordis catalog and event-producer-consumer graph. Regenerate both so the static doc gates pass. --- docs/cordis-catalog/events.md | 12 ++++++------ docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index b0fc141033..e04ce711be 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -937,7 +937,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -961,7 +961,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:165`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -983,7 +983,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:140`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -1006,7 +1006,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:150`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -1027,7 +1027,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:127`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:129`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -1046,7 +1046,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:171`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 01786893ec..3079809e63 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2428,7 +2428,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:725`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:728`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fcf2e98a7e..71ef7e3c28 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -48,12 +48,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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/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:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:150`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:127`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:171`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:140`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:129`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | From d7b4b014eba1f0f692a03e3660ca3aba44f581cc Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 14:31:29 +0800 Subject: [PATCH 036/190] fix(tools): make py-types render total and bound deep class names Address ds-review-bot v5/v6 review round 4: - renderType now holds the no-throw contract across the whole walk, not just root validation: a stateful getter that passes validation and then throws in the render phase degrades the node to Any, rolling back any classes the call had begun emitting, instead of escaping. - allocateClassName caps the accumulated base name. Child class names derive from their parent's, so an unbounded single-field object chain grew the sum of names to Theta(depth^2) (a 5000-deep schema produced a ~25MB SDK); the cap keeps total emitted text linear, the collision counter still makes truncated bases unique. - The language-dispatch note's Consequences first sentence and the zh guard paragraph are corrected: two table entries (not one), and full-width Chinese punctuation per translation-rules.md. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 4 +- packages/core/tools/src/py-types.ts | 277 ++++++++++-------- packages/core/tools/tests/py-types.spec.ts | 62 ++++ 5 files changed, 218 insertions(+), 131 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 6d43a50a7b..632cf62ec7 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: c5643485f5ff9beda8d3f057379242fb4bcc7407 -2026-07-31-code-mode-language-dispatch.zh.md: 889168698215560da1d15799f814d21cff25acf7 +2026-07-31-code-mode-language-dispatch.md: 23794226c8e236421a79fb2143ccb09095f1a287 +2026-07-31-code-mode-language-dispatch.zh.md: d2f868215181a99814c19ca4817582e96b396807 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index c5643485f5..23794226c8 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -33,4 +33,4 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri ## Consequences -Adding a backend language is a table entry plus its renderer, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend; the cost is that a `python` runtime cannot actually be exercised end to end until that backend ships, so this PR's coverage is unit-level (the renderer output and the dispatch/rejection paths) rather than a real Python run. +Adding a backend language is two table entries — a `SDK_RENDERERS` renderer and a `RUN_CODE_FLAVORS` entry — plus the renderer itself, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend; the cost is that a `python` runtime cannot actually be exercised end to end until that backend ships, so this PR's coverage is unit-level (the renderer output and the dispatch/rejection paths) rather than a real Python run. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 8891686982..d2f8682151 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——在语言有渲染器却无 flavor 表项的运行时下读 `ctx.tools.schemas()` 即到达,且有测试覆盖。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——在语言有渲染器却无 flavor 表项的运行时下读 `ctx.tools.schemas()` 即到达,且有测试覆盖。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 @@ -33,4 +33,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言就是一条表项加它的渲染器,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测;代价是在该后端发布前无法真正端到端跑一个 `python` 运行时,故本 PR 的覆盖是 unit 级(渲染器输出与分发/拒绝路径),而非真实的 Python 运行。 +新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 渲染器加一个 `RUN_CODE_FLAVORS` 表项——再加渲染器本身,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测;代价是在该后端发布前无法真正端到端跑一个 `python` 运行时,故本 PR 的覆盖是 unit 级(渲染器输出与分发/拒绝路径),而非真实的 Python 运行。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 697df8ae8d..e6c20d1027 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -121,9 +121,20 @@ function camelCase(raw: string): string { } /** Reserve a unique class name, suffixing a counter on collision after CamelCase sanitization. */ +/** + * Reserve a unique class name from a base, suffixing `2`, `3`, … on collision. + * The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names + * derive from their parent's allocated name (`ParentChild`), so an unbounded + * schema of single-field objects would otherwise grow each name by one field + * per level and the sum of all names to Θ(depth²). Capping the base keeps each + * name — and the total emitted text — linear in depth; the collision counter + * still makes truncated bases unique. + */ +const MAX_CLASS_NAME_BASE = 120 function allocateClassName(base: string, state: RenderState): string { - let name = base - for (let n = 2; state.usedClassNames.has(name); n++) name = `${base}${n}` + const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base + let name = capped + for (let n = 2; state.usedClassNames.has(name); n++) name = `${capped}${n}` state.usedClassNames.add(name) return name } @@ -202,6 +213,12 @@ function renderType(schema: unknown, className: string, state: RenderState): str ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated }) const frames: Frame[] = [newFrame(schema, className, false)] let result: string | undefined + // The no-throw contract must hold across the WHOLE walk, not just the root + // validation: a hostile stateful getter (a `type` that returns a scalar on + // the first read and throws on a later one) reaches the render phase past + // validation. Any throw here degrades to `Any`, discarding classes this call + // partially emitted so no broken declaration escapes. + const classFloor = state.classes.length /* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */ const finish = (type: string): void => { @@ -211,114 +228,115 @@ function renderType(schema: unknown, className: string, state: RenderState): str else parent.childTypes.push(type) } - while (frames.length > 0) { - const frame = frames.at(-1) - /* v8 ignore next -- the loop condition guarantees a current frame. */ - if (frame === undefined) break + try { + while (frames.length > 0) { + const frame = frames.at(-1) + /* v8 ignore next -- the loop condition guarantees a current frame. */ + if (frame === undefined) break - if (frame.phase === 'children') { - if (frame.childIndex < frame.children.length) { - const child = frame.children[frame.childIndex] - /* v8 ignore next -- childIndex is bounded by children.length. */ - if (child === undefined) throw new Error('missing python render child') - frame.childIndex++ - frames.push(newFrame(child.schema, child.className, true)) - continue - } - if (frame.kind === 'oneOf') { - finish(frame.childTypes.join(' | ')) - continue - } - /* jscpd:ignore-end */ - if (frame.kind === 'array') { + if (frame.phase === 'children') { + if (frame.childIndex < frame.children.length) { + const child = frame.children[frame.childIndex] + /* v8 ignore next -- childIndex is bounded by children.length. */ + if (child === undefined) throw new Error('missing python render child') + frame.childIndex++ + frames.push(newFrame(child.schema, child.className, true)) + continue + } + if (frame.kind === 'oneOf') { + finish(frame.childTypes.join(' | ')) + continue + } + /* jscpd:ignore-end */ + if (frame.kind === 'array') { // `list[A | B]` needs no parentheses in Python. Array frames always // schedule exactly one child, so its type is present. /* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */ - finish(`list[${frame.childTypes[0] ?? 'Any'}]`) + finish(`list[${frame.childTypes[0] ?? 'Any'}]`) + continue + } + // typeddict: assemble AFTER the children so any nested class this one + // references is already declared (declaration order = reference order). + const node = frame.node + const name = frame.allocated + /* v8 ignore next -- typeddict frames always set node and allocated at start. */ + if (node === undefined || name === undefined) throw new Error('missing typeddict frame state') + const required = new Set(Array.isArray(node.required) ? node.required.filter((n): n is string => typeof n === 'string') : []) + const lines = [`class ${name}(TypedDict):`] + for (let index = 0; index < frame.entries.length; index++) { + const entry = frame.entries[index] + const fieldType = frame.childTypes[index] + /* v8 ignore next -- entries and childTypes correspond one-to-one. */ + if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type') + const [field, fieldSchema] = entry + // The parent node passed assertSupportedJsonSchema, so every property + // value is a validated schema node (an object). + const description = describe(fieldSchema as object) + if (description !== undefined) lines.push(`${pad(1)}# ${description}`) + if (required.has(field)) { + lines.push(`${pad(1)}${field}: ${fieldType}`) + } else { + state.typing.add('NotRequired') + lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`) + } + } + // TypedDict syntax cannot express openness, so an open object states it + // in-band: the annotation is advisory either way, and Code Mode omits + // the native schemas, making this line the model's only signal that + // extra keys are accepted. + if (node.additionalProperties !== false) { + lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`) + } + // A closed empty object still needs a class body (`pass`) to be valid + // Python; the declared emptiness is the information. + if (lines.length === 1) lines.push(`${pad(1)}pass`) + state.classes.push(lines.join('\n')) + finish(name) continue } - // typeddict: assemble AFTER the children so any nested class this one - // references is already declared (declaration order = reference order). - const node = frame.node - const name = frame.allocated - /* v8 ignore next -- typeddict frames always set node and allocated at start. */ - if (node === undefined || name === undefined) throw new Error('missing typeddict frame state') - const required = new Set(Array.isArray(node.required) ? node.required.filter((n): n is string => typeof n === 'string') : []) - const lines = [`class ${name}(TypedDict):`] - for (let index = 0; index < frame.entries.length; index++) { - const entry = frame.entries[index] - const fieldType = frame.childTypes[index] - /* v8 ignore next -- entries and childTypes correspond one-to-one. */ - if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type') - const [field, fieldSchema] = entry - // The parent node passed assertSupportedJsonSchema, so every property - // value is a validated schema node (an object). - const description = describe(fieldSchema as object) - if (description !== undefined) lines.push(`${pad(1)}# ${description}`) - if (required.has(field)) { - lines.push(`${pad(1)}${field}: ${fieldType}`) - } else { - state.typing.add('NotRequired') - lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`) + + frame.phase = 'children' + // Validate the WHOLE tree once at the root frame (the assertion walks it + // with an explicit stack); child frames are inside that validated tree, so + // re-asserting them would make a deep schema quadratic. + if (!frame.validated) { + try { + assertSupportedJsonSchema(frame.schema) + } catch { + state.typing.add('Any') + finish('Any') + continue } } - // TypedDict syntax cannot express openness, so an open object states it - // in-band: the annotation is advisory either way, and Code Mode omits - // the native schemas, making this line the model's only signal that - // extra keys are accepted. - if (node.additionalProperties !== false) { - lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`) + const node = frame.schema as Record<string, unknown> + if (Object.hasOwn(node, 'oneOf')) { + frame.kind = 'oneOf' + frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + continue } - // A closed empty object still needs a class body (`pass`) to be valid - // Python; the declared emptiness is the information. - if (lines.length === 1) lines.push(`${pad(1)}pass`) - state.classes.push(lines.join('\n')) - finish(name) - continue - } - - frame.phase = 'children' - // Validate the WHOLE tree once at the root frame (the assertion walks it - // with an explicit stack); child frames are inside that validated tree, so - // re-asserting them would make a deep schema quadratic. - if (!frame.validated) { - try { - assertSupportedJsonSchema(frame.schema) - } catch { + if (!Object.hasOwn(node, 'type')) { state.typing.add('Any') finish('Any') continue } - } - const node = frame.schema as Record<string, unknown> - if (Object.hasOwn(node, 'oneOf')) { - frame.kind = 'oneOf' - frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) - continue - } - if (!Object.hasOwn(node, 'type')) { - state.typing.add('Any') - finish('Any') - continue - } - switch (node.type) { - case 'string': finish(renderConstrainedScalar(node, 'str', state)); break - case 'number': finish(renderConstrainedScalar(node, 'float', state)); break - case 'integer': finish(renderConstrainedScalar(node, 'int', state)); break - case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break - case 'null': finish('None'); break - case 'array': { - if (!Object.hasOwn(node, 'items')) { - state.typing.add('Any') - finish('list[Any]') + switch (node.type) { + case 'string': finish(renderConstrainedScalar(node, 'str', state)); break + case 'number': finish(renderConstrainedScalar(node, 'float', state)); break + case 'integer': finish(renderConstrainedScalar(node, 'int', state)); break + case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break + case 'null': finish('None'); break + case 'array': { + if (!Object.hasOwn(node, 'items')) { + state.typing.add('Any') + finish('list[Any]') + break + } + // An array of objects names its item type after the array field. + frame.kind = 'array' + frame.children = [{ schema: node.items, className: frame.className }] break } - // An array of objects names its item type after the array field. - frame.kind = 'array' - frame.children = [{ schema: node.items, className: frame.className }] - break - } - case 'object': { + case 'object': { // A missing `properties` is an empty property map, exactly as the // unified validator and the TS renderer read it — NOT an unknown // shape. assertSupportedJsonSchema already rejected a non-object @@ -326,43 +344,50 @@ function renderType(schema: unknown, className: string, state: RenderState): str // left is omission. The openness of the resulting empty object is // decided below, so a closed empty object still declares an empty // TypedDict rather than a permissive `dict[str, Any]`. - const entries = Object.entries((node.properties ?? {}) as Record<string, unknown>) - // An empty `className` marks the context-free `jsonSchemaToPy` entry: - // there is no naming context to declare into, so degrade. A field - // name that is not a legal Python attribute is inexpressible as a - // class-syntax `TypedDict` field, so such an object degrades whole. - // A leading-double-underscore non-dunder field (`__token`) would be - // NAME-MANGLED inside class syntax (`_ClassName__token`), describing a - // different JSON key than the registered schema — degrade like any - // other inexpressible field name. - if (className === '' || !entries.every(([name]) => IDENTIFIER.test(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) { - state.typing.add('Any') - finish('dict[str, Any]') + const entries = Object.entries((node.properties ?? {}) as Record<string, unknown>) + // An empty `className` marks the context-free `jsonSchemaToPy` entry: + // there is no naming context to declare into, so degrade. A field + // name that is not a legal Python attribute is inexpressible as a + // class-syntax `TypedDict` field, so such an object degrades whole. + // A leading-double-underscore non-dunder field (`__token`) would be + // NAME-MANGLED inside class syntax (`_ClassName__token`), describing a + // different JSON key than the registered schema — degrade like any + // other inexpressible field name. + if (className === '' || !entries.every(([name]) => IDENTIFIER.test(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) { + state.typing.add('Any') + finish('dict[str, Any]') + break + } + // An OPEN empty object is any dict; a CLOSED empty object declares an + // empty TypedDict so "no keys accepted" survives into the SDK. + if (entries.length === 0 && node.additionalProperties !== false) { + state.typing.add('Any') + finish('dict[str, Any]') + break + } + frame.kind = 'typeddict' + frame.node = node + frame.allocated = allocateClassName(frame.className, state) + state.typing.add('TypedDict') + frame.entries = entries + // frame.allocated was assigned two statements up; the ?? arm is for the type system only. + /* v8 ignore next -- allocated is always set before children are built. */ + frame.children = entries.map(([field, child]) => ({ schema: child, className: `${frame.allocated ?? ''}${camelCase(field)}` })) break } - // An OPEN empty object is any dict; a CLOSED empty object declares an - // empty TypedDict so "no keys accepted" survives into the SDK. - if (entries.length === 0 && node.additionalProperties !== false) { + /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */ + default: { state.typing.add('Any') - finish('dict[str, Any]') - break + finish('Any') } - frame.kind = 'typeddict' - frame.node = node - frame.allocated = allocateClassName(frame.className, state) - state.typing.add('TypedDict') - frame.entries = entries - // frame.allocated was assigned two statements up; the ?? arm is for the type system only. - /* v8 ignore next -- allocated is always set before children are built. */ - frame.children = entries.map(([field, child]) => ({ schema: child, className: `${frame.allocated ?? ''}${camelCase(field)}` })) - break - } - /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */ - default: { - state.typing.add('Any') - finish('Any') } } + } catch { + // A render-phase throw (a stateful getter that passed validation) degrades + // the whole node to `Any`; drop any classes this call had begun emitting. + state.classes.length = classFloor + state.typing.add('Any') + return 'Any' } /* v8 ignore next -- every root frame produces one expression. */ return result ?? 'Any' diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 80ae2c2084..4131cbd571 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -51,6 +51,68 @@ describe('jsonSchemaToPy', () => { expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any') }) + it('degrades to Any when a stateful getter throws in the render phase after passing validation', () => { + // A hostile `type` getter returns a scalar on the validation read, then + // throws on the render read. The no-throw contract must still hold across + // the whole walk, degrading the node to Any rather than escaping. + let reads = 0 + const schema = { + get type() { + reads += 1 + if (reads <= 1) return 'string' + throw new Error('stateful getter') + }, + } + expect(() => jsonSchemaToPy(schema)).not.toThrow() + expect(jsonSchemaToPy(schema)).toBe('Any') + }) + + it('rolls back partial class declarations when a nested render-phase throw degrades a tool', () => { + // The throwing field must not leave a half-emitted TypedDict in the output. + let reads = 0 + const hostileField = { + get type() { + reads += 1 + if (reads <= 1) return 'string' + throw new Error('stateful getter') + }, + } + const tool: ToolSdkSchema = { + name: 'hostile', + description: 'Has a field whose getter throws on the render read.', + parameters: { type: 'object', additionalProperties: false, properties: { bad: hostileField as never }, required: ['bad'] }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + // The whole args render degrades to Any (a render-phase throw unwinds the + // entire renderType call); no partial TypedDict for it is declared. + expect(text).toContain('async def hostile(self, args: Any) -> str: ...') + expect(text).not.toContain('class HostileArgs(TypedDict):') + }) + + it('keeps class names and total output linear for a deep single-field object chain', () => { + // Child class names derive from their parent's; without a cap the sum of + // names is Theta(depth^2). Bound it so a deep schema stays linear. + const depth = 4000 + let schema: Record<string, unknown> = { type: 'string' } + for (let i = 0; i < depth; i++) { + schema = { type: 'object', additionalProperties: false, properties: { inner: schema }, required: ['inner'] } + } + const tool: ToolSdkSchema = { + name: 'deep', + description: 'Deeply nested single-field chain.', + parameters: schema, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + // No emitted class name exceeds the cap plus a short collision suffix, so + // total text is O(depth) rather than O(depth^2) (a quadratic 4000-deep + // chain would be tens of MB). + const longestClassName = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0) + expect(longestClassName).toBeLessThanOrEqual(140) + expect(text.length).toBeLessThan(depth * 400) + }) + it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded From 1614f196868067740bab981fee996e34f0069a5e Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 14:50:01 +0800 Subject: [PATCH 037/190] fix(tools): amortize class-name allocation and tighten py-types render contract Address ds-review-bot v5/v6 review round 5: - allocateClassName: keep a per-base collision counter (state.nextClassCounter) so a deep single-field chain sharing one capped base allocates in amortized O(1) instead of rescanning from 2 each time (Theta(depth^2) time); remove the stale one-line JSDoc left above the multiline one and attach the doc to the function, not the constant. - renderType's catch rolls back the typing symbols the discarded subtree added (not just the classes) so the import line still lists exactly the symbols the surviving output uses; the comment now names that the same path also degrades this module's internal-invariant throws to Any, the trade for never throwing. - README (both languages) no longer describes an installable dsh-code-runtime-python package: the Python renderer is built in and drives any runtime reporting language: 'python'; the first-party backend ships separately. - Tests: assert the render-phase degrade on the first call, assert the import line after rollback, and cover the collision-skip loop; py-types.ts stays at 100% per-file coverage. --- packages/core/tools/README.i18n.yaml | 4 +-- packages/core/tools/README.md | 6 ++-- packages/core/tools/README.zh.md | 6 ++-- packages/core/tools/src/py-types.ts | 35 +++++++++++++++----- packages/core/tools/tests/py-types.spec.ts | 37 ++++++++++++++++++++-- 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 11767a300e..a296f1af68 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: ba8310b0b378d27d228a6e551e4b917c33e78fe5 -README.zh.md: 56cc1637f673559fe5f3c7cdf36bec80b8906eaa +README.md: e055fac61d31e1320b051753092e9b874f62a927 +README.zh.md: edfe2032fbe00a66d1a0460a044823723dbe6796 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index ba8310b0b3..e055fac61d 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -13,7 +13,7 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer (TypeScript via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md), Python via `dsh-code-runtime-python`); a runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. ### Public API @@ -145,7 +145,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (via `dsh-code-runtime-python`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). +Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). ##### Code Mode SDK instructions @@ -190,6 +190,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. -- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` via the python backend); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns why per-agent language switching is deferred). +- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns why per-agent language switching is deferred). - **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 56cc1637f6..edfe2032fb 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -13,7 +13,7 @@ tools: mode: native # native (default) | code | both ``` -`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器(TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md),Python 经 `dsh-code-runtime-python`);没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 +`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 ### 公开 API @@ -145,7 +145,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(经 `dsh-code-runtime-python`)形状相同,只是换成 Python 语法(`await tools.name(args)`、异体名用下标访问、`print(...)` 与顶层 `return`)。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、异体名用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 @@ -190,6 +190,6 @@ The available tools: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。 - **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。 -- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 经 python 后端);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责说明为何暂缓逐 agent 切换语言)。 +- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责说明为何暂缓逐 agent 切换语言)。 - **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。 - **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index e6c20d1027..6e904a907c 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -62,6 +62,8 @@ function pad(indent: number): string { interface RenderState { readonly classes: string[] readonly usedClassNames: Set<string> + /** Next collision counter per capped base, so allocation is amortized O(1) instead of rescanning from `2`. */ + readonly nextClassCounter: Map<string, number> readonly typing: Set<string> } @@ -120,21 +122,27 @@ function camelCase(raw: string): string { return /^[A-Za-z]/.test(joined) ? joined : `Tool${joined}` } -/** Reserve a unique class name, suffixing a counter on collision after CamelCase sanitization. */ /** * Reserve a unique class name from a base, suffixing `2`, `3`, … on collision. * The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names * derive from their parent's allocated name (`ParentChild`), so an unbounded * schema of single-field objects would otherwise grow each name by one field * per level and the sum of all names to Θ(depth²). Capping the base keeps each - * name — and the total emitted text — linear in depth; the collision counter - * still makes truncated bases unique. + * name — and the total emitted text — linear in depth. Collisions resume from + * the per-base counter in `state.nextClassCounter` rather than rescanning from + * `2`, so a deep chain sharing one capped base stays O(1) per allocation + * (amortized) instead of Θ(depth²) in time. */ const MAX_CLASS_NAME_BASE = 120 function allocateClassName(base: string, state: RenderState): string { const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base let name = capped - for (let n = 2; state.usedClassNames.has(name); n++) name = `${capped}${n}` + if (state.usedClassNames.has(name)) { + let n = state.nextClassCounter.get(capped) ?? 2 + while (state.usedClassNames.has(`${capped}${n}`)) n++ + name = `${capped}${n}` + state.nextClassCounter.set(capped, n + 1) + } state.usedClassNames.add(name) return name } @@ -219,6 +227,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str // validation. Any throw here degrades to `Any`, discarding classes this call // partially emitted so no broken declaration escapes. const classFloor = state.classes.length + const typingFloor = new Set(state.typing) /* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */ const finish = (type: string): void => { @@ -383,9 +392,19 @@ function renderType(schema: unknown, className: string, state: RenderState): str } } } catch { - // A render-phase throw (a stateful getter that passed validation) degrades - // the whole node to `Any`; drop any classes this call had begun emitting. + // Reached by a render-phase throw the root validation could not catch: + // either a hostile stateful getter (a `type` that passes validation then + // throws on a later read) OR one of this module's own v8-ignored internal + // invariant errors (`missing python render child` etc.). Both degrade the + // whole node to `Any` — an internal renderer bug thus surfaces as a lost + // type rather than a loud crash during prompt assembly, the deliberate + // trade for the never-throw contract. Roll back the classes and typing + // symbols the discarded subtree added so the import line still lists + // exactly the symbols the surviving output uses; `usedClassNames`/counter + // retention is harmless (conservative uniqueness). state.classes.length = classFloor + state.typing.clear() + for (const symbol of typingFloor) state.typing.add(symbol) state.typing.add('Any') return 'Any' } @@ -409,7 +428,7 @@ export function jsonSchemaToPy(schema: unknown): string { // A throwaway state whose class collector never escapes: an object with // properties has nowhere to declare its TypedDict and degrades to // dict[str, Any]. renderToolsSdkPy drives the named-TypedDict path. - return renderType(schema, '', { classes: [], usedClassNames: new Set(), typing: new Set() }) + return renderType(schema, '', { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set() }) } /** The fixed model-facing usage contract rendered above the declarations. */ @@ -441,7 +460,7 @@ The available tools:` */ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) - const state: RenderState = { classes: [], usedClassNames: new Set(), typing: new Set(['Protocol']) } + const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) } const inlineMembers: string[] = [] const subscriptMembers: string[] = [] for (const schema of sorted) { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 4131cbd571..174b8987a2 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -54,7 +54,10 @@ describe('jsonSchemaToPy', () => { it('degrades to Any when a stateful getter throws in the render phase after passing validation', () => { // A hostile `type` getter returns a scalar on the validation read, then // throws on the render read. The no-throw contract must still hold across - // the whole walk, degrading the node to Any rather than escaping. + // the whole walk, degrading the node to Any rather than escaping. Assert + // the FIRST call's result: within it, root validation reads `type` once + // and the render phase reads it again (the throw), so this exercises the + // render-phase catch, not the validation-catch path. let reads = 0 const schema = { get type() { @@ -63,8 +66,9 @@ describe('jsonSchemaToPy', () => { throw new Error('stateful getter') }, } - expect(() => jsonSchemaToPy(schema)).not.toThrow() - expect(jsonSchemaToPy(schema)).toBe('Any') + let first: string | undefined + expect(() => { first = jsonSchemaToPy(schema) }).not.toThrow() + expect(first).toBe('Any') }) it('rolls back partial class declarations when a nested render-phase throw degrades a tool', () => { @@ -88,6 +92,10 @@ describe('jsonSchemaToPy', () => { // entire renderType call); no partial TypedDict for it is declared. expect(text).toContain('async def hostile(self, args: Any) -> str: ...') expect(text).not.toContain('class HostileArgs(TypedDict):') + // The import line lists only symbols the surviving output uses: the + // discarded subtree's TypedDict/NotRequired must not leak into it. + expect(text).not.toContain('TypedDict') + expect(text).toContain('from typing import Any, Protocol') }) it('keeps class names and total output linear for a deep single-field object chain', () => { @@ -113,6 +121,29 @@ describe('jsonSchemaToPy', () => { expect(text.length).toBeLessThan(depth * 400) }) + it('skips an already-taken counter suffix when a sibling object occupies it', () => { + // `phase` and `Phase` both CamelCase to the base `FooArgsPhase`; `phase2` + // independently allocates `FooArgsPhase2` first. When `Phase` collides, the + // counter's first candidate `FooArgsPhase2` is already taken, so the scan + // must advance to `FooArgsPhase3` (exercises the collision-skip loop). + const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } }) + const tool: ToolSdkSchema = { + name: 'foo', + description: 'Sibling objects with colliding class bases.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') }, + required: ['phase', 'phase2', 'Phase'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('class FooArgsPhase(TypedDict):') + expect(text).toContain('class FooArgsPhase2(TypedDict):') + expect(text).toContain('class FooArgsPhase3(TypedDict):') + }) + it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded From 96a2e38fa382fd2c5d2ca5fc072537d7ea039527 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 15:24:10 +0800 Subject: [PATCH 038/190] fix(tools): detect render-phase cycles and fix class-name JSDoc placement Address ds-review-bot v5/v6 review round 6: - renderType tracks the active ancestor schemas by object identity (the frame stack is the DFS path). A stateful getter can mutate the graph after validation so a child returns an ancestor at render time; without this the walk pushed frames forever instead of degrading. A repeated ancestor now degrades to Any, honoring the never-throw contract; distinct nodes in a legitimately deep chain are different objects, so it stays O(1) per push and O(depth) memory. - The multiline allocateClassName JSDoc was still attached to the MAX_CLASS_NAME_BASE constant (a self-referential @link, and the function had no doc). Move the doc onto the function and give the constant its own one-liner. - Tests cover the post-validation cycle and a non-object render-time child; py-types.ts stays at 100% per-file coverage. --- packages/core/tools/src/py-types.ts | 30 ++++++++++++++++-- packages/core/tools/tests/py-types.spec.ts | 37 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 6e904a907c..7d2a89867f 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -122,6 +122,9 @@ function camelCase(raw: string): string { return /^[A-Za-z]/.test(joined) ? joined : `Tool${joined}` } +/** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */ +const MAX_CLASS_NAME_BASE = 120 + /** * Reserve a unique class name from a base, suffixing `2`, `3`, … on collision. * The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names @@ -133,7 +136,6 @@ function camelCase(raw: string): string { * `2`, so a deep chain sharing one capped base stays O(1) per allocation * (amortized) instead of Θ(depth²) in time. */ -const MAX_CLASS_NAME_BASE = 120 function allocateClassName(base: string, state: RenderState): string { const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base let name = capped @@ -220,6 +222,15 @@ function renderType(schema: unknown, className: string, state: RenderState): str const newFrame = (schema: unknown, className: string, validated: boolean): Frame => ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated }) const frames: Frame[] = [newFrame(schema, className, false)] + // Ancestor schemas by object identity — the frame stack IS the DFS path, so + // this set holds exactly the current node's ancestors. A stateful getter can + // mutate the graph after validation (an `items`/property that validated as a + // scalar but returns an ancestor at render time); without this, the walk + // would push frames forever. A repeated ancestor degrades to `Any` per the + // never-throw contract. Distinct nodes in a legitimately deep chain are all + // different objects, so this stays O(1) per push and O(depth) memory. + const activeSchemas = new Set<object>() + if (typeof schema === 'object' && schema !== null) activeSchemas.add(schema) let result: string | undefined // The no-throw contract must hold across the WHOLE walk, not just the root // validation: a hostile stateful getter (a `type` that returns a scalar on @@ -231,7 +242,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str /* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */ const finish = (type: string): void => { - frames.pop() + const popped = frames.pop() + if (popped !== undefined && typeof popped.schema === 'object' && popped.schema !== null) { + activeSchemas.delete(popped.schema) + } const parent = frames.at(-1) if (parent === undefined) result = type else parent.childTypes.push(type) @@ -249,6 +263,18 @@ function renderType(schema: unknown, className: string, state: RenderState): str /* v8 ignore next -- childIndex is bounded by children.length. */ if (child === undefined) throw new Error('missing python render child') frame.childIndex++ + // A child schema already on the active path is a cycle a post- + // validation mutation introduced; degrade it to `Any` rather than + // recurse forever. A fresh object joins the path (finish removes it); + // a non-object child carries no identity to track. + if (typeof child.schema === 'object' && child.schema !== null) { + if (activeSchemas.has(child.schema)) { + state.typing.add('Any') + frame.childTypes.push('Any') + continue + } + activeSchemas.add(child.schema) + } frames.push(newFrame(child.schema, child.className, true)) continue } diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 174b8987a2..1293390fec 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -144,6 +144,43 @@ describe('jsonSchemaToPy', () => { expect(text).toContain('class FooArgsPhase3(TypedDict):') }) + it('degrades to Any instead of looping when a stateful getter introduces a cycle after validation', () => { + // `items` validates as a scalar, then returns the root schema at render + // time — a cycle a post-validation mutation introduced. The walk must + // degrade to Any rather than push frames forever. + let itemReads = 0 + const root: Record<string, unknown> = { type: 'array' } + Object.defineProperty(root, 'items', { + enumerable: true, + get() { + itemReads += 1 + return itemReads <= 1 ? { type: 'string' } : root + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() + // list[...] of a self-cycle: the inner cycle degrades to Any. + expect(out).toBe('list[Any]') + }) + + it('degrades to Any when a stateful getter returns a non-object child at render time', () => { + // `items` validates as a scalar node, then returns a bare string (a + // non-object) at render. The walk must handle a non-object child without + // tracking identity and degrade it, not throw. + let itemReads = 0 + const root: Record<string, unknown> = { type: 'array' } + Object.defineProperty(root, 'items', { + enumerable: true, + get() { + itemReads += 1 + return itemReads <= 1 ? { type: 'string' } : 'not-a-schema-object' + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() + expect(out).toBe('list[Any]') + }) + it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded From 51189a650cd21cfec197fe6320450dc948ba236e Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 15:35:19 +0800 Subject: [PATCH 039/190] fix(tools): track functions in cycle detection and guard scalar re-reads Address ds-review-bot v5/v6 review round 7: - The render-walk cycle guard tracked only plain objects; a function has typeof 'function' yet carries own properties and can reference itself, so a post-validation getter returning a self-referential function bypassed the guard and looped forever. A hasIdentity() helper now covers objects AND functions, applied symmetrically at the three sites (root add, finish remove, child check). - renderConstrainedScalar re-reads const/enum at render time; a stateful getter that validated as a scalar could return an object, spelling the invalid Literal[[object Object]]. It now degrades to the broad type when the re-read value is not a scalar (or the enum not an all-scalar array). - The activeSchemas comment notes the out-of-scope boundary: a getter fabricating a fresh node per read never repeats an ancestor and is indistinguishable from a legitimately unbounded-depth schema. Tests cover the function cycle and non-scalar const/enum re-reads; py-types.ts stays at 100% per-file coverage. --- packages/core/tools/src/py-types.ts | 51 +++++++++++++++----- packages/core/tools/tests/py-types.spec.ts | 56 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 7d2a89867f..272b5e7ac1 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -20,6 +20,17 @@ import type { ToolSdkSchema } from './ts-types.ts' /** Property names that are valid bare Python identifiers; anything else is subscripted. */ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ +/** + * Whether a schema value carries a trackable reference identity for the render + * walk's cycle detection. Both plain objects AND functions qualify: a function + * has `typeof 'function'` yet can carry own properties (`oneOf`, `items`) and + * reference itself, so a post-validation getter returning a self-referential + * function would otherwise bypass the object-only guard and loop forever. + */ +function hasIdentity(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function' +} + /** * Python hard keywords: reserved everywhere, so a tool or field named * ``class`` or ``lambda`` is legal on the wire but not as an attribute @@ -175,6 +186,11 @@ function pyScalar(value: JsonSchemaScalar): string { return String(value) } +/** Whether a value is a JSON scalar `Literal[...]` can spell (a re-read getter may return anything). */ +function isPyScalar(value: unknown): value is JsonSchemaScalar { + return value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string' +} + /** * Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to * the broad type. Deliberately deviates from PEP 586, which restricts `Literal` @@ -185,12 +201,18 @@ function pyScalar(value: JsonSchemaScalar): string { */ function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string { if (Object.hasOwn(node, 'const')) { + // Re-read at render time: a stateful getter validated as a scalar can now + // return anything. A non-scalar would spell `Literal[[object Object]]` + // (invalid Python), so degrade to the broad type per the contract. + if (!isPyScalar(node.const)) return broad state.typing.add('Literal') - return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]` + return `Literal[${pyScalar(node.const)}]` } if (Object.hasOwn(node, 'enum')) { + const raw = node.enum + if (!Array.isArray(raw) || !raw.every(isPyScalar)) return broad state.typing.add('Literal') - return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]` + return `Literal[${raw.map(pyScalar).join(', ')}]` } return broad } @@ -222,15 +244,20 @@ function renderType(schema: unknown, className: string, state: RenderState): str const newFrame = (schema: unknown, className: string, validated: boolean): Frame => ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated }) const frames: Frame[] = [newFrame(schema, className, false)] - // Ancestor schemas by object identity — the frame stack IS the DFS path, so - // this set holds exactly the current node's ancestors. A stateful getter can - // mutate the graph after validation (an `items`/property that validated as a - // scalar but returns an ancestor at render time); without this, the walk + // Ancestor schemas by reference identity — the frame stack IS the DFS path, + // so this set holds exactly the current node's ancestors. A stateful getter + // can mutate the graph after validation (an `items`/property that validated + // as a scalar but returns an ancestor at render time); without this, the walk // would push frames forever. A repeated ancestor degrades to `Any` per the // never-throw contract. Distinct nodes in a legitimately deep chain are all - // different objects, so this stays O(1) per push and O(depth) memory. + // different references, so this stays O(1) per push and O(depth) memory. + // Both objects and functions are tracked (see {@link hasIdentity}). Out of + // scope: a getter fabricating a FRESH node per read never repeats an ancestor + // and is locally indistinguishable from a legitimately unbounded-depth schema + // (which this module supports), so cycle detection is the reachable best + // defense rather than a depth cap that would break the legitimate case. const activeSchemas = new Set<object>() - if (typeof schema === 'object' && schema !== null) activeSchemas.add(schema) + if (hasIdentity(schema)) activeSchemas.add(schema) let result: string | undefined // The no-throw contract must hold across the WHOLE walk, not just the root // validation: a hostile stateful getter (a `type` that returns a scalar on @@ -243,7 +270,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */ const finish = (type: string): void => { const popped = frames.pop() - if (popped !== undefined && typeof popped.schema === 'object' && popped.schema !== null) { + if (popped !== undefined && hasIdentity(popped.schema)) { activeSchemas.delete(popped.schema) } const parent = frames.at(-1) @@ -265,9 +292,9 @@ function renderType(schema: unknown, className: string, state: RenderState): str frame.childIndex++ // A child schema already on the active path is a cycle a post- // validation mutation introduced; degrade it to `Any` rather than - // recurse forever. A fresh object joins the path (finish removes it); - // a non-object child carries no identity to track. - if (typeof child.schema === 'object' && child.schema !== null) { + // recurse forever. A fresh reference joins the path (finish removes + // it); a value with no reference identity carries none to track. + if (hasIdentity(child.schema)) { if (activeSchemas.has(child.schema)) { state.typing.add('Any') frame.childTypes.push('Any') diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 1293390fec..7c24ef9788 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -181,6 +181,62 @@ describe('jsonSchemaToPy', () => { expect(out).toBe('list[Any]') }) + it('degrades to Any when a stateful getter returns a self-referential function as a child', () => { + // A function has typeof 'function' yet can carry own props and reference + // itself; the cycle guard must track it too, or the walk loops forever. + let itemReads = 0 + const root: Record<string, unknown> = { type: 'array' } + const fn = Object.assign(function () {}, {}) as Record<string, unknown> & (() => void) + ;(fn as Record<string, unknown>).oneOf = [fn] + Object.defineProperty(root, 'items', { + enumerable: true, + get() { + itemReads += 1 + return itemReads <= 1 ? { type: 'string' } : fn + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() + expect(out).toBe('list[Any]') + }) + + it('degrades to the broad type when a const getter re-reads as a non-scalar', () => { + // `const` validates as a string, then returns an object at render time. + // A naive spelling would emit Literal[[object Object]] (invalid Python); + // the render must fall back to the broad type instead. + let reads = 0 + const schema: Record<string, unknown> = { type: 'string' } + Object.defineProperty(schema, 'const', { + enumerable: true, + get() { + reads += 1 + return reads <= 1 ? 'fixed' : {} + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out).toBe('str') + expect(out).not.toContain('object Object') + }) + + it('degrades to the broad type when an enum getter re-reads as a non-scalar array', () => { + // `enum` validates as scalars, then returns an array containing an object + // at render time; the render must fall back to the broad type. + let reads = 0 + const schema: Record<string, unknown> = { type: 'string' } + Object.defineProperty(schema, 'enum', { + enumerable: true, + get() { + reads += 1 + return reads <= 1 ? ['a', 'b'] : [{}] + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out).toBe('str') + expect(out).not.toContain('object Object') + }) + it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded From 7518a5cb6548563e6b970bf9f21ea9927590abfd Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 15:51:23 +0800 Subject: [PATCH 040/190] fix(tools): snapshot const/enum/oneOf reads to close stateful-getter TOCTOU Address ds-review-bot v5/v6 review round 8. The prior guards re-read a stateful getter's value between the check and the spelling, so a getter returning different values across reads could still emit invalid Python: - renderConstrainedScalar reads node.const ONCE into a local, then checks and spells that snapshot; a third-read switch can no longer produce Literal[[object Object]]. - The enum path snapshots via [...raw] (reading each element exactly once, covering accessor-property elements) and requires the snapshot be a non-empty all-scalar array; an emptied re-read no longer spells Literal[], and a non-array re-read degrades. - The oneOf branch build guards a non-array or empty re-read to Any instead of joining to '' (a missing type). - pyScalar spells null as None; its JSDoc no longer claims null cannot reach it. Tests cover each re-read shape; py-types.ts stays at 100% coverage. --- packages/core/tools/src/py-types.ts | 41 +++++--- packages/core/tools/tests/py-types.spec.ts | 109 +++++++++++++++++++-- 2 files changed, 130 insertions(+), 20 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 272b5e7ac1..b989d7fc55 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -161,10 +161,11 @@ function allocateClassName(base: string, state: RenderState): string { } /** - * Render one validated scalar as Python literal text (`True`/`False`, - * JSON-quoted strings, bare numbers). `null` cannot reach here: the `null` - * type renders directly as `None`, and the unified validator rejects a null - * `const`/`enum` entry on every other scalar type. + * Render one validated scalar as Python literal text (`True`/`False`, `None`, + * JSON-quoted strings, bare numbers). A validated `const`/`enum` never carries + * a bare `null` on a non-`null` scalar type, but a post-validation stateful + * getter can re-read one as `null`, so `null` is spelled `None` rather than the + * JS `String(null)` = `"null"`. * * A beyond-safe-range integral number takes `BigInt` digits rather than * `String`: Python integers are arbitrary-precision, so the emitted digits ARE @@ -179,6 +180,7 @@ function allocateClassName(base: string, state: RenderState): string { function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' if (value === false) return 'False' + if (value === null) return 'None' if (typeof value === 'string') return JSON.stringify(value) if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) { return BigInt(value).toString() @@ -201,18 +203,24 @@ function isPyScalar(value: unknown): value is JsonSchemaScalar { */ function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string { if (Object.hasOwn(node, 'const')) { - // Re-read at render time: a stateful getter validated as a scalar can now - // return anything. A non-scalar would spell `Literal[[object Object]]` - // (invalid Python), so degrade to the broad type per the contract. - if (!isPyScalar(node.const)) return broad + // Snapshot the value with ONE read: a stateful getter can return different + // values across reads, so a separate check-read and spell-read could still + // pass the check and then spell a non-scalar (`Literal[[object Object]]`). + const value = node.const + if (!isPyScalar(value)) return broad state.typing.add('Literal') - return `Literal[${pyScalar(node.const)}]` + return `Literal[${pyScalar(value)}]` } if (Object.hasOwn(node, 'enum')) { const raw = node.enum - if (!Array.isArray(raw) || !raw.every(isPyScalar)) return broad + // `[...raw]` reads each element exactly once (elements may be accessor + // properties that change between reads); then check and spell that + // snapshot. Require non-empty: an emptied re-read would spell `Literal[]`, + // a Python SyntaxError that breaks the whole SDK. + const values: unknown[] | undefined = Array.isArray(raw) ? [...(raw as unknown[])] : undefined + if (values === undefined || values.length === 0 || !values.every(isPyScalar)) return broad state.typing.add('Literal') - return `Literal[${raw.map(pyScalar).join(', ')}]` + return `Literal[${values.map(pyScalar).join(', ')}]` } return broad } @@ -372,8 +380,17 @@ function renderType(schema: unknown, className: string, state: RenderState): str } const node = frame.schema as Record<string, unknown> if (Object.hasOwn(node, 'oneOf')) { + // Snapshot the branches with ONE read (a getter can change them + // between reads). A re-read that is not a non-empty array would join to + // `''` (or drop branches), so degrade to `Any` instead. + const branches = node.oneOf + if (!Array.isArray(branches) || branches.length === 0) { + state.typing.add('Any') + finish('Any') + continue + } frame.kind = 'oneOf' - frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + frame.children = (branches as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) continue } if (!Object.hasOwn(node, 'type')) { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 7c24ef9788..5bb10573be 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -200,10 +200,9 @@ describe('jsonSchemaToPy', () => { expect(out).toBe('list[Any]') }) - it('degrades to the broad type when a const getter re-reads as a non-scalar', () => { - // `const` validates as a string, then returns an object at render time. - // A naive spelling would emit Literal[[object Object]] (invalid Python); - // the render must fall back to the broad type instead. + it('degrades a const that snapshots as a non-scalar to the broad type', () => { + // The single snapshot read returns an object (validation read returned a + // scalar); the check must degrade rather than spell Literal[[object Object]]. let reads = 0 const schema: Record<string, unknown> = { type: 'string' } Object.defineProperty(schema, 'const', { @@ -219,24 +218,118 @@ describe('jsonSchemaToPy', () => { expect(out).not.toContain('object Object') }) - it('degrades to the broad type when an enum getter re-reads as a non-scalar array', () => { - // `enum` validates as scalars, then returns an array containing an object - // at render time; the render must fall back to the broad type. + it('snapshots const with one read so a third-read switch cannot spell a non-scalar', () => { + // A getter returning 'fixed' on the validation AND check reads but an + // object on a third read would defeat a separate check-read/spell-read. + // The render snapshots once, so it either spells the checked value or + // degrades — never Literal[[object Object]]. + let reads = 0 + const schema: Record<string, unknown> = { type: 'string' } + Object.defineProperty(schema, 'const', { + enumerable: true, + get() { + reads += 1 + return reads <= 2 ? 'fixed' : {} + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out === 'str' || out === 'Literal["fixed"]').toBe(true) + expect(out).not.toContain('object Object') + }) + + it('degrades to the broad type when an enum getter re-reads as a non-array', () => { + // A validated enum array that re-reads as a non-array must degrade, not + // spread a non-iterable or spell a bad literal. let reads = 0 const schema: Record<string, unknown> = { type: 'string' } Object.defineProperty(schema, 'enum', { enumerable: true, get() { reads += 1 - return reads <= 1 ? ['a', 'b'] : [{}] + return reads <= 1 ? ['a'] : 'not-an-array' }, }) let out: string | undefined expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() expect(out).toBe('str') + }) + + it('degrades to the broad type when an enum getter re-reads as an empty array', () => { + // A validated non-empty enum that re-reads as [] would spell Literal[] — a + // Python SyntaxError that breaks the whole SDK. Require non-empty at render. + let reads = 0 + const schema: Record<string, unknown> = { type: 'string' } + Object.defineProperty(schema, 'enum', { + enumerable: true, + get() { + reads += 1 + return reads <= 1 ? ['a'] : [] + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out).toBe('str') + expect(out).not.toContain('Literal[]') + }) + + it('degrades the broad type when an enum element is an accessor that re-reads as a non-scalar', () => { + // `[...raw]` reads each element exactly once; the validation read saw a + // scalar, the spread read returns an object. The snapshot's every(isPyScalar) + // check must degrade rather than spell Literal[[object Object]]. + let elemReads = 0 + const arr: unknown[] = [] + Object.defineProperty(arr, '0', { + enumerable: true, + configurable: true, + get() { + elemReads += 1 + return elemReads <= 1 ? 'a' : {} + }, + }) + arr.length = 1 + const schema = { type: 'string', enum: arr } + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out).toBe('str') expect(out).not.toContain('object Object') }) + it('spells a const re-read as null with None, not the JS string "null"', () => { + let reads = 0 + const schema: Record<string, unknown> = { type: 'string' } + Object.defineProperty(schema, 'const', { + enumerable: true, + get() { + reads += 1 + return reads <= 1 ? 'fixed' : null + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + // Either the checked value spells, or a null re-read spells None — never "null". + expect(out === 'Literal["fixed"]' || out === 'Literal[None]').toBe(true) + expect(out).not.toContain('Literal[null]') + }) + + it('degrades a oneOf that re-reads as an empty array to Any, not an empty string', () => { + // oneOf validates as two branches, then returns [] at render; a naive join + // would produce '' (a missing type). Degrade to Any instead. + let reads = 0 + const schema: Record<string, unknown> = {} + Object.defineProperty(schema, 'oneOf', { + enumerable: true, + get() { + reads += 1 + return reads <= 1 ? [{ type: 'string' }, { type: 'number' }] : [] + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out).toBe('Any') + expect(out).not.toBe('') + }) + it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded From f61b138e0835b47ae8157057e3477702f9591c5f Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 16:25:01 +0800 Subject: [PATCH 041/190] refactor(tools): restore py-types to the ts-types trusted-after-validation stance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds 6-9 of the bot review kept finding adjacent hostile-getter variants (post-validation cycles, TOCTOU on const/enum/oneOf, self-referential functions) because the renderer had grown per-shape runtime defenses the sibling ts-types renderer does not have. Those inputs are unreachable: the schema is a first-party defineTool object literal that already passed assertSupportedJsonSchema, and per AGENTS.md "Trust TypeScript at typed same-process seams" a typed same-process seam does not add hostile-input handling for values the static interface forbids. renderType now validates the whole tree once and trusts it, wrapping the walk in one try/catch that degrades to Any — byte-for-byte the stance of the ts-types sibling. This removes the cycle-tracking (activeSchemas/hasIdentity), the const/enum/oneOf read snapshots, the isPyScalar re-check, the typing rollback, and the pyScalar null->None re-read handling; the corresponding hostile-getter tests are removed. Behavior fixes that hold for legitimate input are kept: RESERVED soft-keyword exclusion, closed-empty-object TypedDict, class-name cap + per-base collision counter, BigInt digits for beyond-safe integers. py-types.ts stays at 100% per-file coverage. The language-dispatch Agent Note documents the stance and its symmetry with ts-types so the boundary is not re-litigated. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 + ...26-07-31-code-mode-language-dispatch.zh.md | 2 + packages/core/tools/src/py-types.ts | 195 +++-------- packages/core/tools/tests/py-types.spec.ts | 316 ++---------------- 5 files changed, 95 insertions(+), 424 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 632cf62ec7..fb6dcecc95 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 23794226c8e236421a79fb2143ccb09095f1a287 -2026-07-31-code-mode-language-dispatch.zh.md: d2f868215181a99814c19ca4817582e96b396807 +2026-07-31-code-mode-language-dispatch.md: 9f001b8fad8ca954d9b0c3cdca0e7be4d3b9ce61 +2026-07-31-code-mode-language-dispatch.zh.md: 525bb8a97e4e6d9e00334d5425acd1491a9b3fc7 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 23794226c8..9f001b8fad 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -25,6 +25,8 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri `py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. +`renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party `defineTool` object literal that already passed validation, so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". + ## Alternatives considered - **A `language` config field on `ToolRegistry`.** Deployment would then have two places to name the language (the loaded runtime and the tools config) that can disagree; the loaded runtime is the single source of truth, so the registry reads it rather than duplicating it. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index d2f8682151..525bb8a97e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -25,6 +25,8 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd `py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。 +`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的"校验后信任"姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对"访问器在多次读取间变值"的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是已通过校验的第一方 `defineTool` 对象字面量,这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是"降级不支持的 schema",而非"扛住对抗性的可变 schema"。 + ## Alternatives considered - **在 `ToolRegistry` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真相来源,故注册表读取它而不复制它。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index b989d7fc55..ef5a122dc4 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -20,17 +20,6 @@ import type { ToolSdkSchema } from './ts-types.ts' /** Property names that are valid bare Python identifiers; anything else is subscripted. */ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ -/** - * Whether a schema value carries a trackable reference identity for the render - * walk's cycle detection. Both plain objects AND functions qualify: a function - * has `typeof 'function'` yet can carry own properties (`oneOf`, `items`) and - * reference itself, so a post-validation getter returning a self-referential - * function would otherwise bypass the object-only guard and loop forever. - */ -function hasIdentity(value: unknown): value is object { - return (typeof value === 'object' && value !== null) || typeof value === 'function' -} - /** * Python hard keywords: reserved everywhere, so a tool or field named * ``class`` or ``lambda`` is legal on the wire but not as an attribute @@ -67,8 +56,8 @@ function pad(indent: number): string { /** * Collector threaded through {@link renderType}: the emitted `TypedDict` class * declarations (nested classes precede the parent that references them), the - * class names already taken (for collision suffixing), and the `typing` - * symbols the render actually used. + * class names already taken (for collision suffixing), a per-base collision + * counter, and the `typing` symbols the render actually used. */ interface RenderState { readonly classes: string[] @@ -161,11 +150,10 @@ function allocateClassName(base: string, state: RenderState): string { } /** - * Render one validated scalar as Python literal text (`True`/`False`, `None`, - * JSON-quoted strings, bare numbers). A validated `const`/`enum` never carries - * a bare `null` on a non-`null` scalar type, but a post-validation stateful - * getter can re-read one as `null`, so `null` is spelled `None` rather than the - * JS `String(null)` = `"null"`. + * Render one validated scalar as Python literal text (`True`/`False`, + * JSON-quoted strings, bare numbers). `null` cannot reach here: the `null` + * type renders directly as `None`, and the unified validator rejects a null + * `const`/`enum` entry on every other scalar type. * * A beyond-safe-range integral number takes `BigInt` digits rather than * `String`: Python integers are arbitrary-precision, so the emitted digits ARE @@ -180,7 +168,6 @@ function allocateClassName(base: string, state: RenderState): string { function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' if (value === false) return 'False' - if (value === null) return 'None' if (typeof value === 'string') return JSON.stringify(value) if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) { return BigInt(value).toString() @@ -188,11 +175,6 @@ function pyScalar(value: JsonSchemaScalar): string { return String(value) } -/** Whether a value is a JSON scalar `Literal[...]` can spell (a re-read getter may return anything). */ -function isPyScalar(value: unknown): value is JsonSchemaScalar { - return value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string' -} - /** * Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to * the broad type. Deliberately deviates from PEP 586, which restricts `Literal` @@ -203,24 +185,12 @@ function isPyScalar(value: unknown): value is JsonSchemaScalar { */ function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string { if (Object.hasOwn(node, 'const')) { - // Snapshot the value with ONE read: a stateful getter can return different - // values across reads, so a separate check-read and spell-read could still - // pass the check and then spell a non-scalar (`Literal[[object Object]]`). - const value = node.const - if (!isPyScalar(value)) return broad state.typing.add('Literal') - return `Literal[${pyScalar(value)}]` + return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]` } if (Object.hasOwn(node, 'enum')) { - const raw = node.enum - // `[...raw]` reads each element exactly once (elements may be accessor - // properties that change between reads); then check and spell that - // snapshot. Require non-empty: an emptied re-read would spell `Literal[]`, - // a Python SyntaxError that breaks the whole SDK. - const values: unknown[] | undefined = Array.isArray(raw) ? [...(raw as unknown[])] : undefined - if (values === undefined || values.length === 0 || !values.every(isPyScalar)) return broad state.typing.add('Literal') - return `Literal[${values.map(pyScalar).join(', ')}]` + return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]` } return broad } @@ -231,9 +201,10 @@ function renderConstrainedScalar(node: Record<string, unknown>, broad: string, s * needs. `className` is the name to give an object node with properties (and * the prefix for its nested objects). Handles every unified schema construct — * `oneOf` (→ `X | Y`), `const`/`enum` (→ `Literal[...]`), `integer` (→ `int`), - * `null` (→ `None`) — and degrades malformed or unsupported inputs to `Any` - * without throwing. {@link jsonSchemaToPy} is the context-free entry point; - * this is the collecting core. + * `null` (→ `None`) — and degrades an unsupported or malformed schema to `Any` + * without throwing, the same trusted-after-validation stance as the sibling + * {@link ./ts-types.ts | ts-types} renderer. {@link jsonSchemaToPy} is the + * context-free entry point; this is the collecting core. */ function renderType(schema: unknown, className: string, state: RenderState): string { interface Frame { @@ -247,46 +218,28 @@ function renderType(schema: unknown, className: string, state: RenderState): str childTypes: string[] entries: [string, unknown][] allocated?: string - validated: boolean } - const newFrame = (schema: unknown, className: string, validated: boolean): Frame => - ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated }) - const frames: Frame[] = [newFrame(schema, className, false)] - // Ancestor schemas by reference identity — the frame stack IS the DFS path, - // so this set holds exactly the current node's ancestors. A stateful getter - // can mutate the graph after validation (an `items`/property that validated - // as a scalar but returns an ancestor at render time); without this, the walk - // would push frames forever. A repeated ancestor degrades to `Any` per the - // never-throw contract. Distinct nodes in a legitimately deep chain are all - // different references, so this stays O(1) per push and O(depth) memory. - // Both objects and functions are tracked (see {@link hasIdentity}). Out of - // scope: a getter fabricating a FRESH node per read never repeats an ancestor - // and is locally indistinguishable from a legitimately unbounded-depth schema - // (which this module supports), so cycle detection is the reachable best - // defense rather than a depth cap that would break the legitimate case. - const activeSchemas = new Set<object>() - if (hasIdentity(schema)) activeSchemas.add(schema) - let result: string | undefined - // The no-throw contract must hold across the WHOLE walk, not just the root - // validation: a hostile stateful getter (a `type` that returns a scalar on - // the first read and throws on a later one) reaches the render phase past - // validation. Any throw here degrades to `Any`, discarding classes this call - // partially emitted so no broken declaration escapes. - const classFloor = state.classes.length - const typingFloor = new Set(state.typing) - /* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels - ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */ - const finish = (type: string): void => { - const popped = frames.pop() - if (popped !== undefined && hasIdentity(popped.schema)) { - activeSchemas.delete(popped.schema) - } - const parent = frames.at(-1) - if (parent === undefined) result = type - else parent.childTypes.push(type) - } - + const newFrame = (schema: unknown, className: string): Frame => + ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [] }) try { + // Validate the WHOLE tree once, then trust it — the same contract the + // sibling ts-types renderer follows at a typed same-process seam. Every + // node past this point is a validated JSON-schema node, so the walk reads + // its fields without re-checking. An unsupported or malformed schema throws + // here (before anything is emitted) and degrades to `Any`, the Python + // counterpart of the TS flavor's `unknown`. + assertSupportedJsonSchema(schema) + const frames: Frame[] = [newFrame(schema, className)] + let result: string | undefined + /* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels + ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */ + const finish = (type: string): void => { + frames.pop() + const parent = frames.at(-1) + if (parent === undefined) result = type + else parent.childTypes.push(type) + } + while (frames.length > 0) { const frame = frames.at(-1) /* v8 ignore next -- the loop condition guarantees a current frame. */ @@ -298,19 +251,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str /* v8 ignore next -- childIndex is bounded by children.length. */ if (child === undefined) throw new Error('missing python render child') frame.childIndex++ - // A child schema already on the active path is a cycle a post- - // validation mutation introduced; degrade it to `Any` rather than - // recurse forever. A fresh reference joins the path (finish removes - // it); a value with no reference identity carries none to track. - if (hasIdentity(child.schema)) { - if (activeSchemas.has(child.schema)) { - state.typing.add('Any') - frame.childTypes.push('Any') - continue - } - activeSchemas.add(child.schema) - } - frames.push(newFrame(child.schema, child.className, true)) + frames.push(newFrame(child.schema, child.className)) continue } if (frame.kind === 'oneOf') { @@ -319,9 +260,9 @@ function renderType(schema: unknown, className: string, state: RenderState): str } /* jscpd:ignore-end */ if (frame.kind === 'array') { - // `list[A | B]` needs no parentheses in Python. Array frames always - // schedule exactly one child, so its type is present. - /* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */ + // `list[A | B]` needs no parentheses in Python. Array frames always + // schedule exactly one child, so its type is present. + /* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */ finish(`list[${frame.childTypes[0] ?? 'Any'}]`) continue } @@ -366,31 +307,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str } frame.phase = 'children' - // Validate the WHOLE tree once at the root frame (the assertion walks it - // with an explicit stack); child frames are inside that validated tree, so - // re-asserting them would make a deep schema quadratic. - if (!frame.validated) { - try { - assertSupportedJsonSchema(frame.schema) - } catch { - state.typing.add('Any') - finish('Any') - continue - } - } const node = frame.schema as Record<string, unknown> if (Object.hasOwn(node, 'oneOf')) { - // Snapshot the branches with ONE read (a getter can change them - // between reads). A re-read that is not a non-empty array would join to - // `''` (or drop branches), so degrade to `Any` instead. - const branches = node.oneOf - if (!Array.isArray(branches) || branches.length === 0) { - state.typing.add('Any') - finish('Any') - continue - } frame.kind = 'oneOf' - frame.children = (branches as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) continue } if (!Object.hasOwn(node, 'type')) { @@ -416,13 +336,11 @@ function renderType(schema: unknown, className: string, state: RenderState): str break } case 'object': { - // A missing `properties` is an empty property map, exactly as the - // unified validator and the TS renderer read it — NOT an unknown - // shape. assertSupportedJsonSchema already rejected a non-object - // `properties` (degraded to `Any` above), so the only non-map case - // left is omission. The openness of the resulting empty object is - // decided below, so a closed empty object still declares an empty - // TypedDict rather than a permissive `dict[str, Any]`. + // A missing `properties` is an empty property map, exactly as the + // unified validator and the TS renderer read it — NOT an unknown + // shape. The openness of the resulting empty object is decided below, + // so a closed empty object still declares an empty TypedDict rather + // than a permissive `dict[str, Any]`. const entries = Object.entries((node.properties ?? {}) as Record<string, unknown>) // An empty `className` marks the context-free `jsonSchemaToPy` entry: // there is no naming context to declare into, so degrade. A field @@ -461,25 +379,16 @@ function renderType(schema: unknown, className: string, state: RenderState): str } } } + /* v8 ignore next -- every root frame produces one expression. */ + return result ?? 'Any' } catch { - // Reached by a render-phase throw the root validation could not catch: - // either a hostile stateful getter (a `type` that passes validation then - // throws on a later read) OR one of this module's own v8-ignored internal - // invariant errors (`missing python render child` etc.). Both degrade the - // whole node to `Any` — an internal renderer bug thus surfaces as a lost - // type rather than a loud crash during prompt assembly, the deliberate - // trade for the never-throw contract. Roll back the classes and typing - // symbols the discarded subtree added so the import line still lists - // exactly the symbols the surviving output uses; `usedClassNames`/counter - // retention is harmless (conservative uniqueness). - state.classes.length = classFloor - state.typing.clear() - for (const symbol of typingFloor) state.typing.add(symbol) + // An unsupported or malformed schema failed validation (before any + // emission), or an unreachable internal invariant tripped. Either degrades + // the node to `Any` rather than crashing prompt assembly — the Python + // counterpart of the TS flavor's `unknown` fallback. state.typing.add('Any') return 'Any' } - /* v8 ignore next -- every root frame produces one expression. */ - return result ?? 'Any' } /** @@ -488,10 +397,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str * to `dict[str, Any]`: naming a `TypedDict` requires the render context that * {@link renderToolsSdkPy} supplies), `const`/`enum` (→ `Literal[...]`), * `oneOf` (→ union), `string`/`number`/`integer`/`boolean`/`null`, `array` - * (`items` → `list[T]`) — and returns `Any` for anything else, without - * throwing. Type annotations in the emitted SDK are advisory: Python does not - * enforce them at runtime, matching the TS flavor's advisory-type stance. - * @param schema - the JSON-Schema node (any shape; hostile inputs degrade). + * (`items` → `list[T]`) — and returns `Any` for an unsupported or malformed + * schema, matching the TS flavor's `unknown` fallback. Type annotations in the + * emitted SDK are advisory: Python does not enforce them at runtime. + * @param schema - the JSON-Schema node. * @returns the Python type text. */ export function jsonSchemaToPy(schema: unknown): string { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 5bb10573be..89db13d852 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -51,285 +51,6 @@ describe('jsonSchemaToPy', () => { expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any') }) - it('degrades to Any when a stateful getter throws in the render phase after passing validation', () => { - // A hostile `type` getter returns a scalar on the validation read, then - // throws on the render read. The no-throw contract must still hold across - // the whole walk, degrading the node to Any rather than escaping. Assert - // the FIRST call's result: within it, root validation reads `type` once - // and the render phase reads it again (the throw), so this exercises the - // render-phase catch, not the validation-catch path. - let reads = 0 - const schema = { - get type() { - reads += 1 - if (reads <= 1) return 'string' - throw new Error('stateful getter') - }, - } - let first: string | undefined - expect(() => { first = jsonSchemaToPy(schema) }).not.toThrow() - expect(first).toBe('Any') - }) - - it('rolls back partial class declarations when a nested render-phase throw degrades a tool', () => { - // The throwing field must not leave a half-emitted TypedDict in the output. - let reads = 0 - const hostileField = { - get type() { - reads += 1 - if (reads <= 1) return 'string' - throw new Error('stateful getter') - }, - } - const tool: ToolSdkSchema = { - name: 'hostile', - description: 'Has a field whose getter throws on the render read.', - parameters: { type: 'object', additionalProperties: false, properties: { bad: hostileField as never }, required: ['bad'] }, - output: { type: 'string' }, - } - const text = renderToolsSdkPy([tool]) - // The whole args render degrades to Any (a render-phase throw unwinds the - // entire renderType call); no partial TypedDict for it is declared. - expect(text).toContain('async def hostile(self, args: Any) -> str: ...') - expect(text).not.toContain('class HostileArgs(TypedDict):') - // The import line lists only symbols the surviving output uses: the - // discarded subtree's TypedDict/NotRequired must not leak into it. - expect(text).not.toContain('TypedDict') - expect(text).toContain('from typing import Any, Protocol') - }) - - it('keeps class names and total output linear for a deep single-field object chain', () => { - // Child class names derive from their parent's; without a cap the sum of - // names is Theta(depth^2). Bound it so a deep schema stays linear. - const depth = 4000 - let schema: Record<string, unknown> = { type: 'string' } - for (let i = 0; i < depth; i++) { - schema = { type: 'object', additionalProperties: false, properties: { inner: schema }, required: ['inner'] } - } - const tool: ToolSdkSchema = { - name: 'deep', - description: 'Deeply nested single-field chain.', - parameters: schema, - output: { type: 'string' }, - } - const text = renderToolsSdkPy([tool]) - // No emitted class name exceeds the cap plus a short collision suffix, so - // total text is O(depth) rather than O(depth^2) (a quadratic 4000-deep - // chain would be tens of MB). - const longestClassName = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0) - expect(longestClassName).toBeLessThanOrEqual(140) - expect(text.length).toBeLessThan(depth * 400) - }) - - it('skips an already-taken counter suffix when a sibling object occupies it', () => { - // `phase` and `Phase` both CamelCase to the base `FooArgsPhase`; `phase2` - // independently allocates `FooArgsPhase2` first. When `Phase` collides, the - // counter's first candidate `FooArgsPhase2` is already taken, so the scan - // must advance to `FooArgsPhase3` (exercises the collision-skip loop). - const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } }) - const tool: ToolSdkSchema = { - name: 'foo', - description: 'Sibling objects with colliding class bases.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') }, - required: ['phase', 'phase2', 'Phase'], - }, - output: { type: 'string' }, - } - const text = renderToolsSdkPy([tool]) - expect(text).toContain('class FooArgsPhase(TypedDict):') - expect(text).toContain('class FooArgsPhase2(TypedDict):') - expect(text).toContain('class FooArgsPhase3(TypedDict):') - }) - - it('degrades to Any instead of looping when a stateful getter introduces a cycle after validation', () => { - // `items` validates as a scalar, then returns the root schema at render - // time — a cycle a post-validation mutation introduced. The walk must - // degrade to Any rather than push frames forever. - let itemReads = 0 - const root: Record<string, unknown> = { type: 'array' } - Object.defineProperty(root, 'items', { - enumerable: true, - get() { - itemReads += 1 - return itemReads <= 1 ? { type: 'string' } : root - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() - // list[...] of a self-cycle: the inner cycle degrades to Any. - expect(out).toBe('list[Any]') - }) - - it('degrades to Any when a stateful getter returns a non-object child at render time', () => { - // `items` validates as a scalar node, then returns a bare string (a - // non-object) at render. The walk must handle a non-object child without - // tracking identity and degrade it, not throw. - let itemReads = 0 - const root: Record<string, unknown> = { type: 'array' } - Object.defineProperty(root, 'items', { - enumerable: true, - get() { - itemReads += 1 - return itemReads <= 1 ? { type: 'string' } : 'not-a-schema-object' - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() - expect(out).toBe('list[Any]') - }) - - it('degrades to Any when a stateful getter returns a self-referential function as a child', () => { - // A function has typeof 'function' yet can carry own props and reference - // itself; the cycle guard must track it too, or the walk loops forever. - let itemReads = 0 - const root: Record<string, unknown> = { type: 'array' } - const fn = Object.assign(function () {}, {}) as Record<string, unknown> & (() => void) - ;(fn as Record<string, unknown>).oneOf = [fn] - Object.defineProperty(root, 'items', { - enumerable: true, - get() { - itemReads += 1 - return itemReads <= 1 ? { type: 'string' } : fn - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() - expect(out).toBe('list[Any]') - }) - - it('degrades a const that snapshots as a non-scalar to the broad type', () => { - // The single snapshot read returns an object (validation read returned a - // scalar); the check must degrade rather than spell Literal[[object Object]]. - let reads = 0 - const schema: Record<string, unknown> = { type: 'string' } - Object.defineProperty(schema, 'const', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? 'fixed' : {} - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('str') - expect(out).not.toContain('object Object') - }) - - it('snapshots const with one read so a third-read switch cannot spell a non-scalar', () => { - // A getter returning 'fixed' on the validation AND check reads but an - // object on a third read would defeat a separate check-read/spell-read. - // The render snapshots once, so it either spells the checked value or - // degrades — never Literal[[object Object]]. - let reads = 0 - const schema: Record<string, unknown> = { type: 'string' } - Object.defineProperty(schema, 'const', { - enumerable: true, - get() { - reads += 1 - return reads <= 2 ? 'fixed' : {} - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out === 'str' || out === 'Literal["fixed"]').toBe(true) - expect(out).not.toContain('object Object') - }) - - it('degrades to the broad type when an enum getter re-reads as a non-array', () => { - // A validated enum array that re-reads as a non-array must degrade, not - // spread a non-iterable or spell a bad literal. - let reads = 0 - const schema: Record<string, unknown> = { type: 'string' } - Object.defineProperty(schema, 'enum', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? ['a'] : 'not-an-array' - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('str') - }) - - it('degrades to the broad type when an enum getter re-reads as an empty array', () => { - // A validated non-empty enum that re-reads as [] would spell Literal[] — a - // Python SyntaxError that breaks the whole SDK. Require non-empty at render. - let reads = 0 - const schema: Record<string, unknown> = { type: 'string' } - Object.defineProperty(schema, 'enum', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? ['a'] : [] - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('str') - expect(out).not.toContain('Literal[]') - }) - - it('degrades the broad type when an enum element is an accessor that re-reads as a non-scalar', () => { - // `[...raw]` reads each element exactly once; the validation read saw a - // scalar, the spread read returns an object. The snapshot's every(isPyScalar) - // check must degrade rather than spell Literal[[object Object]]. - let elemReads = 0 - const arr: unknown[] = [] - Object.defineProperty(arr, '0', { - enumerable: true, - configurable: true, - get() { - elemReads += 1 - return elemReads <= 1 ? 'a' : {} - }, - }) - arr.length = 1 - const schema = { type: 'string', enum: arr } - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('str') - expect(out).not.toContain('object Object') - }) - - it('spells a const re-read as null with None, not the JS string "null"', () => { - let reads = 0 - const schema: Record<string, unknown> = { type: 'string' } - Object.defineProperty(schema, 'const', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? 'fixed' : null - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - // Either the checked value spells, or a null re-read spells None — never "null". - expect(out === 'Literal["fixed"]' || out === 'Literal[None]').toBe(true) - expect(out).not.toContain('Literal[null]') - }) - - it('degrades a oneOf that re-reads as an empty array to Any, not an empty string', () => { - // oneOf validates as two branches, then returns [] at render; a naive join - // would produce '' (a missing type). Degrade to Any instead. - let reads = 0 - const schema: Record<string, unknown> = {} - Object.defineProperty(schema, 'oneOf', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? [{ type: 'string' }, { type: 'number' }] : [] - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('Any') - expect(out).not.toBe('') - }) - it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded @@ -549,6 +270,43 @@ describe('renderToolsSdkPy', () => { expect(text).toContain('class MyToolArgs2(TypedDict):') }) + it('caps class-name length so a deep single-field chain stays linear', () => { + // Child class names derive from their parent's, so without a cap the sum of + // names would be Theta(depth^2). MAX_CLASS_NAME_BASE (120) bounds each name. + const depth = 4000 + let schema: Record<string, unknown> = { type: 'string' } + for (let i = 0; i < depth; i++) { + schema = { type: 'object', additionalProperties: false, properties: { inner: schema }, required: ['inner'] } + } + const tool: ToolSdkSchema = { name: 'deep', description: 'Deep chain.', parameters: schema, output: { type: 'string' } } + const text = renderToolsSdkPy([tool]) + const longestClassName = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0) + expect(longestClassName).toBeLessThanOrEqual(140) + expect(text.length).toBeLessThan(depth * 400) + }) + + it('skips an already-taken counter suffix when a sibling object occupies it', () => { + // `phase` and `Phase` both CamelCase to base `FooArgsPhase`; `phase2` + // independently takes `FooArgsPhase2`, so `Phase`'s collision scan must + // advance to `FooArgsPhase3` (exercises the collision-skip loop). + const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } }) + const tool: ToolSdkSchema = { + name: 'foo', + description: 'Sibling objects with colliding class bases.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') }, + required: ['phase', 'phase2', 'Phase'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('class FooArgsPhase(TypedDict):') + expect(text).toContain('class FooArgsPhase2(TypedDict):') + expect(text).toContain('class FooArgsPhase3(TypedDict):') + }) + it('references the named TypedDict from a reserved/subscript tool too', () => { const tool: ToolSdkSchema = { name: 'class', From cabeaed1eb85862ea561b59a899acc3d61501f47 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 16:42:32 +0800 Subject: [PATCH 042/190] refactor(tools): type the py-types render frame and tighten the note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the bot's review of the trusted-after-validation revert: - renderType's Frame now carries JsonSchemaNode (the root schema is asserted before any frame is built), dropping the `as Record<string, unknown>` casts, the `node.oneOf as unknown[]` cast, and the runtime `required` filter — the same typed-frame shape as the sibling ts-types renderer, so the "symmetric with ts-types" claim holds structurally, not just behaviorally. - The language-dispatch note broadens the trusted-input argument to cover all real sources (first-party defineTool/raw registration and wire-derived plain JSON), and the zh side uses full-width punctuation per translation-rules.md. py-types.ts stays at 100% per-file coverage. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 44 ++++++++++--------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index fb6dcecc95..1bab3fef1a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 9f001b8fad8ca954d9b0c3cdca0e7be4d3b9ce61 -2026-07-31-code-mode-language-dispatch.zh.md: 525bb8a97e4e6d9e00334d5425acd1491a9b3fc7 +2026-07-31-code-mode-language-dispatch.md: 2fdda0f886630b27037d715ede21300f8ae9177f +2026-07-31-code-mode-language-dispatch.zh.md: 7bf82a856b7578462c7bb1fed40d8108b82cda57 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 9f001b8fad..2fdda0f886 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -25,7 +25,7 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri `py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. -`renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party `defineTool` object literal that already passed validation, so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". +`renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party registration (a `defineTool` literal or a raw registration) or a wire-derived plain JSON schema — the former is trusted per AGENTS.md, the latter is a `JSON.parse` product that physically cannot carry accessors, and `renderType` re-validates the whole tree on every call regardless — so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 525bb8a97e..7bf82a856b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -25,7 +25,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd `py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。 -`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的"校验后信任"姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对"访问器在多次读取间变值"的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是已通过校验的第一方 `defineTool` 对象字面量,这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是"降级不支持的 schema",而非"扛住对抗性的可变 schema"。 +`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的「校验后信任」姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对「访问器在多次读取间变值」的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是第一方注册(`defineTool` 字面量或 raw 注册)或从 wire 桥接而来的纯 JSON——前者按 AGENTS.md 受信任,后者是 `JSON.parse` 产物、物理上不可能携带访问器,且每次调用 `renderType` 都会整树重新校验——这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是「降级不支持的 schema」,而非「扛住对抗性的可变 schema」。 ## Alternatives considered diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index ef5a122dc4..22e459bc3e 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -14,7 +14,7 @@ */ import { assertSupportedJsonSchema } from './json-schema.ts' -import type { JsonSchemaScalar } from './json-schema.ts' +import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' import type { ToolSdkSchema } from './ts-types.ts' /** Property names that are valid bare Python identifiers; anything else is subscripted. */ @@ -183,14 +183,14 @@ function pyScalar(value: JsonSchemaScalar): string { * the stub is advisory prompt text, only required to parse — and keeping the * exact value communicates the constraint to the model. */ -function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string { - if (Object.hasOwn(node, 'const')) { +function renderConstrainedScalar(node: JsonSchemaNode, broad: string, state: RenderState): string { + if (node.const !== undefined) { state.typing.add('Literal') - return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]` + return `Literal[${pyScalar(node.const)}]` } - if (Object.hasOwn(node, 'enum')) { + if (node.enum !== undefined) { state.typing.add('Literal') - return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]` + return `Literal[${node.enum.map(pyScalar).join(', ')}]` } return broad } @@ -208,18 +208,22 @@ function renderConstrainedScalar(node: Record<string, unknown>, broad: string, s */ function renderType(schema: unknown, className: string, state: RenderState): string { interface Frame { - schema: unknown + // A validated JSON-schema node past the root `assertSupportedJsonSchema` + // (the root frame's schema is asserted before any frame is built), so the + // walk reads its fields without casts — the same typed-frame shape as the + // sibling ts-types renderer. + schema: JsonSchemaNode className: string phase: 'start' | 'children' kind?: 'oneOf' | 'array' | 'typeddict' - node?: Record<string, unknown> - children: { schema: unknown; className: string }[] + node?: JsonSchemaNode + children: { schema: JsonSchemaNode; className: string }[] childIndex: number childTypes: string[] - entries: [string, unknown][] + entries: [string, JsonSchemaNode][] allocated?: string } - const newFrame = (schema: unknown, className: string): Frame => + const newFrame = (schema: JsonSchemaNode, className: string): Frame => ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [] }) try { // Validate the WHOLE tree once, then trust it — the same contract the @@ -272,7 +276,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str const name = frame.allocated /* v8 ignore next -- typeddict frames always set node and allocated at start. */ if (node === undefined || name === undefined) throw new Error('missing typeddict frame state') - const required = new Set(Array.isArray(node.required) ? node.required.filter((n): n is string => typeof n === 'string') : []) + const required = new Set(node.required) const lines = [`class ${name}(TypedDict):`] for (let index = 0; index < frame.entries.length; index++) { const entry = frame.entries[index] @@ -281,8 +285,8 @@ function renderType(schema: unknown, className: string, state: RenderState): str if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type') const [field, fieldSchema] = entry // The parent node passed assertSupportedJsonSchema, so every property - // value is a validated schema node (an object). - const description = describe(fieldSchema as object) + // value is a validated schema node. + const description = describe(fieldSchema) if (description !== undefined) lines.push(`${pad(1)}# ${description}`) if (required.has(field)) { lines.push(`${pad(1)}${field}: ${fieldType}`) @@ -307,13 +311,13 @@ function renderType(schema: unknown, className: string, state: RenderState): str } frame.phase = 'children' - const node = frame.schema as Record<string, unknown> - if (Object.hasOwn(node, 'oneOf')) { + const node = frame.schema + if (node.oneOf !== undefined) { frame.kind = 'oneOf' - frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) continue } - if (!Object.hasOwn(node, 'type')) { + if (node.type === undefined) { state.typing.add('Any') finish('Any') continue @@ -325,7 +329,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break case 'null': finish('None'); break case 'array': { - if (!Object.hasOwn(node, 'items')) { + if (node.items === undefined) { state.typing.add('Any') finish('list[Any]') break @@ -341,7 +345,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str // shape. The openness of the resulting empty object is decided below, // so a closed empty object still declares an empty TypedDict rather // than a permissive `dict[str, Any]`. - const entries = Object.entries((node.properties ?? {}) as Record<string, unknown>) + const entries = Object.entries(node.properties ?? {}) // An empty `className` marks the context-free `jsonSchemaToPy` entry: // there is no naming context to declare into, so degrade. A field // name that is not a legal Python attribute is inexpressible as a From 13f6af4949336174e2736c05a2f2ab1eee77dcfa Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 16:59:50 +0800 Subject: [PATCH 043/190] docs(tools): reword the language-dispatch note's two-entries sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Consequences sentence called one of the two table entries "a SDK_RENDERERS renderer" — circular, since the entry is the renderer mapping. Reword to "an SDK_RENDERERS entry and a RUN_CODE_FLAVORS entry, plus the renderer function the former points at" in both languages. --- .../feature/2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../feature/2026-07-31-code-mode-language-dispatch.md | 2 +- .../feature/2026-07-31-code-mode-language-dispatch.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 1bab3fef1a..9c803c37ab 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 2fdda0f886630b27037d715ede21300f8ae9177f -2026-07-31-code-mode-language-dispatch.zh.md: 7bf82a856b7578462c7bb1fed40d8108b82cda57 +2026-07-31-code-mode-language-dispatch.md: 1eadc05db9b95cd0365c124480e3977db4ede242 +2026-07-31-code-mode-language-dispatch.zh.md: 046456bfceb391a4771e61e431ff7182e7f9abdf diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 2fdda0f886..1eadc05db9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -35,4 +35,4 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri ## Consequences -Adding a backend language is two table entries — a `SDK_RENDERERS` renderer and a `RUN_CODE_FLAVORS` entry — plus the renderer itself, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend; the cost is that a `python` runtime cannot actually be exercised end to end until that backend ships, so this PR's coverage is unit-level (the renderer output and the dispatch/rejection paths) rather than a real Python run. +Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend; the cost is that a `python` runtime cannot actually be exercised end to end until that backend ships, so this PR's coverage is unit-level (the renderer output and the dispatch/rejection paths) rather than a real Python run. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 7bf82a856b..046456bfce 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -35,4 +35,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 渲染器加一个 `RUN_CODE_FLAVORS` 表项——再加渲染器本身,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测;代价是在该后端发布前无法真正端到端跑一个 `python` 运行时,故本 PR 的覆盖是 unit 级(渲染器输出与分发/拒绝路径),而非真实的 Python 运行。 +新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测;代价是在该后端发布前无法真正端到端跑一个 `python` 运行时,故本 PR 的覆盖是 unit 级(渲染器输出与分发/拒绝路径),而非真实的 Python 运行。 From 282b0d7443eda6eb89b4f5d68e1be1deb827240b Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 17:11:36 +0800 Subject: [PATCH 044/190] docs(tools): align SDK_RENDERERS comment with the note wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK_RENDERERS JSDoc kept the circular "a renderer here … plus the renderer itself" phrasing the note already fixed, and its {@link RUN_CODE_FLAVORS} pointed at a non-exported const in another module (unresolvable). Reword to "an entry here and a RUN_CODE_FLAVORS entry in code-mode.ts … plus the renderer function this table points at". --- packages/core/tools/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index a5851d154e..1fea06065b 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -31,9 +31,9 @@ import { renderToolsSdkPy } from './py-types.ts' * `ctx.codeRuntime.language` in this table when assembling the `tools:sdk` * section under a non-native mode; a runtime whose language is not a key * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a - * new backend language is two table entries — a renderer here and a - * {@link RUN_CODE_FLAVORS} entry for its `run_code` schema strings — plus the - * renderer itself. + * new backend language is two table entries — an entry here and a + * `RUN_CODE_FLAVORS` entry in `code-mode.ts` for its `run_code` schema strings + * — plus the renderer function this table points at. */ const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = { typescript: renderToolsSdk, From b0e405a679647b37c36d2c3811ae4c306d7520cb Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 17:22:38 +0800 Subject: [PATCH 045/190] perf(tools): keep py-types oneOf rendering linear in schema depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deep oneOf chain joined the accumulated union string at every level (Array.join forces materialization), making it Theta(depth^2) — a 50,000-level chain took ~7.6s. Concatenate with `+` instead: V8 builds a lazy ConsString that materializes once at the root, matching the array arm's template-literal laziness and ts-types' composable-document approach. The whole walk is now linear in depth. Adds a 20,000-level oneOf test alongside the existing deep-array one; py-types.ts stays at 100% coverage. --- packages/core/tools/src/py-types.ts | 12 +++++++++++- packages/core/tools/tests/py-types.spec.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 22e459bc3e..6e69541fb9 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -259,7 +259,17 @@ function renderType(schema: unknown, className: string, state: RenderState): str continue } if (frame.kind === 'oneOf') { - finish(frame.childTypes.join(' | ')) + // Concatenate with `+` (not `Array.join`): V8 builds a lazy + // ConsString, so a deep oneOf chain materializes once at the root + // instead of re-materializing the accumulated string at every level + // (which `join` would, making it Θ(depth²)). This matches the array + // arm's template-literal laziness and ts-types' composable-document + // approach — the whole walk stays linear in schema depth. + let union = '' + for (const [index, childType] of frame.childTypes.entries()) { + union = index === 0 ? childType : `${union} | ${childType}` + } + finish(union) continue } /* jscpd:ignore-end */ diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 89db13d852..aa0bc30cff 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -469,6 +469,20 @@ describe('renderToolsSdkPy', () => { expect(type.length).toBe('list['.length * 20000 + 'str'.length + ']'.repeat(20000).length) }) + it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => { + // Each level is a two-branch oneOf whose first branch recurses; joining the + // accumulated union string at every level would be Theta(depth^2). The `+` + // (ConsString) concatenation keeps it linear, like the array arm. + const depth = 20000 + let deep: Record<string, unknown> = { type: 'string' } + for (let i = 0; i < depth; i++) deep = { oneOf: [deep, { type: 'null' }] } + const type = jsonSchemaToPy(deep) + // depth levels of ` | None` appended to the innermost `str`. + expect(type.startsWith('str | None')).toBe(true) + expect(type.endsWith(' | None')).toBe(true) + expect(type.length).toBe('str'.length + ' | None'.length * depth) + }) + it('emits pass for a subscript-only tool set (comments are not statements)', () => { const t: ToolSdkSchema = { name: 'my-exotic.tool', From 345375747eedfc6cacee2cc039c4536145d7cab6 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 17:36:04 +0800 Subject: [PATCH 046/190] perf(tools): cap propagated class names so deep oneOf-object chains stay linear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oneOf perf fix left a second Θ(depth²): a deep oneOf chain whose branches are named objects propagated an ever-growing ConsString as the class-name base, which allocateClassName then re-materialized (.length/.slice) at every level. A childClassName helper now caps the base AT PROPAGATION, so each level is O(1) and the walk is linear; the collision counter still makes truncated bases unique. Also reword the oneOf comment (it said `+` but the code uses a template literal — both are ConsString) and strengthen the tests: the deep oneOf test now runs 100k levels (a quadratic regression trips the 5s timeout), plus a 60k oneOf-object chain and a >120-char tool-name cap case. py-types.ts stays at 100% per-file coverage. --- packages/core/tools/src/py-types.ts | 29 ++++++++++---- packages/core/tools/tests/py-types.spec.ts | 44 ++++++++++++++++++++-- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 6e69541fb9..39d9644420 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -149,6 +149,19 @@ function allocateClassName(base: string, state: RenderState): string { return name } +/** + * Append a child-name segment to a parent class-name base, capping the result + * at {@link MAX_CLASS_NAME_BASE}. Capping AT PROPAGATION (not only inside + * {@link allocateClassName}) keeps each level O(1): a deep `oneOf`- or + * object-chain would otherwise carry an ever-growing ConsString down the tree + * and re-materialize it (via `.length`/`.slice`) at every level — Θ(depth²). + * The bounded base plus the collision counter still yields unique names. + */ +function childClassName(base: string, segment: string): string { + const joined = `${base}${segment}` + return joined.length > MAX_CLASS_NAME_BASE ? joined.slice(0, MAX_CLASS_NAME_BASE) : joined +} + /** * Render one validated scalar as Python literal text (`True`/`False`, * JSON-quoted strings, bare numbers). `null` cannot reach here: the `null` @@ -259,12 +272,12 @@ function renderType(schema: unknown, className: string, state: RenderState): str continue } if (frame.kind === 'oneOf') { - // Concatenate with `+` (not `Array.join`): V8 builds a lazy - // ConsString, so a deep oneOf chain materializes once at the root - // instead of re-materializing the accumulated string at every level - // (which `join` would, making it Θ(depth²)). This matches the array - // arm's template-literal laziness and ts-types' composable-document - // approach — the whole walk stays linear in schema depth. + // Concatenate incrementally (template literal, not `Array.join`): V8 + // builds a lazy ConsString, so a deep oneOf chain materializes once + // at the root instead of re-materializing the accumulated string at + // every level (which `join` would, making it Θ(depth²)). This matches + // the array arm's template-literal laziness and ts-types' composable- + // document approach — the whole walk stays linear in schema depth. let union = '' for (const [index, childType] of frame.childTypes.entries()) { union = index === 0 ? childType : `${union} | ${childType}` @@ -324,7 +337,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str const node = frame.schema if (node.oneOf !== undefined) { frame.kind = 'oneOf' - frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`) })) continue } if (node.type === undefined) { @@ -383,7 +396,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str frame.entries = entries // frame.allocated was assigned two statements up; the ?? arm is for the type system only. /* v8 ignore next -- allocated is always set before children are built. */ - frame.children = entries.map(([field, child]) => ({ schema: child, className: `${frame.allocated ?? ''}${camelCase(field)}` })) + frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)) })) break } /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */ diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index aa0bc30cff..3cea474e8d 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -471,18 +471,54 @@ describe('renderToolsSdkPy', () => { it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => { // Each level is a two-branch oneOf whose first branch recurses; joining the - // accumulated union string at every level would be Theta(depth^2). The `+` - // (ConsString) concatenation keeps it linear, like the array arm. - const depth = 20000 + // accumulated union string at every level would be Theta(depth^2). At this + // depth the quadratic path (~100,000^2 char copies) blows past vitest's 5s + // default, so this fails loud on a regression; the `+`/ConsString path is + // milliseconds. (Guard the depth explicitly so the assertions stay exact.) + const depth = 100000 let deep: Record<string, unknown> = { type: 'string' } for (let i = 0; i < depth; i++) deep = { oneOf: [deep, { type: 'null' }] } const type = jsonSchemaToPy(deep) - // depth levels of ` | None` appended to the innermost `str`. expect(type.startsWith('str | None')).toBe(true) expect(type.endsWith(' | None')).toBe(true) expect(type.length).toBe('str'.length + ' | None'.length * depth) }) + it('names a deep oneOf-of-object chain in linear time (bounded propagated class names)', () => { + // Every level is a oneOf whose first branch is a closed empty object (a + // named TypedDict) and recurses. Propagating the full ancestor path as the + // class name and slicing it in allocateClassName at every level would be + // Theta(depth^2); childClassName caps the propagated base so it stays + // linear. The quadratic path at this depth exceeds the 5s default. + const depth = 60000 + let deep: Record<string, unknown> = { type: 'object', additionalProperties: false, properties: {} } + for (let i = 0; i < depth; i++) { + deep = { oneOf: [deep, { type: 'null' }] } + } + const tool: ToolSdkSchema = { name: 'deep', description: 'Deep oneOf-object chain.', parameters: { type: 'object', additionalProperties: false, properties: { root: deep }, required: ['root'] }, output: { type: 'string' } } + const text = renderToolsSdkPy([tool]) + // No emitted class name exceeds the cap (plus a short collision suffix). + const longest = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0) + expect(longest).toBeLessThanOrEqual(140) + expect(text).toContain('class Tools(Protocol):') + }) + + it('caps the class name for a tool whose name exceeds the base length limit', () => { + // The root class base is `${CamelCase(name)}Args`; a very long tool name + // makes it exceed MAX_CLASS_NAME_BASE, so allocateClassName caps it. + const longName = `x_${'a'.repeat(200)}` + const tool: ToolSdkSchema = { + name: longName, + description: 'Long name.', + parameters: { type: 'object', additionalProperties: false, properties: { f: { type: 'string' } }, required: ['f'] }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + const longest = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0) + expect(longest).toBeLessThanOrEqual(140) + expect(text).toContain('class Tools(Protocol):') + }) + it('emits pass for a subscript-only tool set (comments are not statements)', () => { const t: ToolSdkSchema = { name: 'my-exotic.tool', From 0d6191d0db18203941760c8b8ea6c5085adddafe Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sun, 2 Aug 2026 17:48:33 +0800 Subject: [PATCH 047/190] test(tools): make the deep oneOf-object test a real quadratic tripwire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60k oneOf-object test had only one object node (the innermost), so the pre-fix code called allocateClassName once — linear, never tripping the timeout, so it did not cover the class-name Θ(depth²) it named. Give every level an object branch (both oneOf arms are objects) so each level propagates a one-segment-longer class name; the pre-fix rope slice is then Θ(depth²) (~9.5s, past the 5s default) while the capped path stays linear. Also extract the shared cap expression into capClassNameBase (used by allocateClassName and childClassName). py-types.ts stays at 100% per-file coverage. --- packages/core/tools/src/py-types.ts | 10 +++++++--- packages/core/tools/tests/py-types.spec.ts | 14 ++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 39d9644420..a03ebd61fc 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -125,6 +125,11 @@ function camelCase(raw: string): string { /** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */ const MAX_CLASS_NAME_BASE = 120 +/** Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for why capping keeps the render linear). */ +function capClassNameBase(base: string): string { + return base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base +} + /** * Reserve a unique class name from a base, suffixing `2`, `3`, … on collision. * The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names @@ -137,7 +142,7 @@ const MAX_CLASS_NAME_BASE = 120 * (amortized) instead of Θ(depth²) in time. */ function allocateClassName(base: string, state: RenderState): string { - const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base + const capped = capClassNameBase(base) let name = capped if (state.usedClassNames.has(name)) { let n = state.nextClassCounter.get(capped) ?? 2 @@ -158,8 +163,7 @@ function allocateClassName(base: string, state: RenderState): string { * The bounded base plus the collision counter still yields unique names. */ function childClassName(base: string, segment: string): string { - const joined = `${base}${segment}` - return joined.length > MAX_CLASS_NAME_BASE ? joined.slice(0, MAX_CLASS_NAME_BASE) : joined + return capClassNameBase(`${base}${segment}`) } /** diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 3cea474e8d..0cc748e408 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -485,15 +485,17 @@ describe('renderToolsSdkPy', () => { }) it('names a deep oneOf-of-object chain in linear time (bounded propagated class names)', () => { - // Every level is a oneOf whose first branch is a closed empty object (a - // named TypedDict) and recurses. Propagating the full ancestor path as the - // class name and slicing it in allocateClassName at every level would be - // Theta(depth^2); childClassName caps the propagated base so it stays - // linear. The quadratic path at this depth exceeds the 5s default. + // Every level is a oneOf whose SECOND branch is a named object (a closed + // empty TypedDict) and whose first branch recurses — so every level has an + // object node, each propagating a class name one segment longer. Without a + // propagation cap, allocateClassName slices an ever-longer rope at every + // level → Theta(depth^2) (~9.5s at this depth, past the 5s default); + // childClassName caps the base so it stays linear (~ms). Assertions are + // shape-based but the depth is the tripwire: a regression times out. const depth = 60000 let deep: Record<string, unknown> = { type: 'object', additionalProperties: false, properties: {} } for (let i = 0; i < depth; i++) { - deep = { oneOf: [deep, { type: 'null' }] } + deep = { oneOf: [deep, { type: 'object', additionalProperties: false, properties: {} }] } } const tool: ToolSdkSchema = { name: 'deep', description: 'Deep oneOf-object chain.', parameters: { type: 'object', additionalProperties: false, properties: { root: deep }, required: ['root'] }, output: { type: 'string' } } const text = renderToolsSdkPy([tool]) From 1ee167aeaca76ef483db6d2e3a2c6ba1f110161f Mon Sep 17 00:00:00 2001 From: _Kerman <kermanx@qq.com> Date: Mon, 3 Aug 2026 19:49:30 +0800 Subject: [PATCH 048/190] feat(fs): append recovery remedy to guarded-mutation errors write/edit failures with FS_STALE_VERSION or FS_NOT_OBSERVED now reach the model with the correct recovery instruction appended (re-read / read, then retry) while preserving the structured code and chaining the cause. The edit-intent waterfall sits inside the same try, so the policy's FS_NOT_OBSERVED refusal is remediated too. Re-recorded the fs-policy-reject keyless snapshot and the bilingual README pairs. --- .../snapshots/fs-policy-reject/session.jsonl | 2 +- packages/fs/fs-policy/README.i18n.yaml | 4 +- packages/fs/fs-policy/README.md | 2 +- packages/fs/fs-policy/README.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/fs/tool-fs/src/edit.ts | 14 +++- packages/fs/tool-fs/src/error.ts | 34 ++++++++ packages/fs/tool-fs/src/write.ts | 6 +- packages/fs/tool-fs/tests/error.spec.ts | 35 ++++++++ packages/fs/tool-fs/tests/integration.spec.ts | 80 +++++++++++++++++++ packages/fs/tool-fs/tests/tools.spec.ts | 3 +- 13 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 packages/fs/tool-fs/src/error.ts create mode 100644 packages/fs/tool-fs/tests/error.spec.ts diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 07d1408c9e..87cd7427f9 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":78,"time":1785487602271,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1785487602271,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bd34189-fb62-4106-9c25-b6022d48e059"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} {"type":"tool/call","seq":80,"time":1785487602272,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":81,"time":1785487602280,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"c4018c31-b6fd-4f14-af3c-e609863bf501"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1785487602280,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"c4018c31-b6fd-4f14-af3c-e609863bf501"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1785487602280,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1785487602287,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1783611704931,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml index 6690227dbc..5168b43d34 100644 --- a/packages/fs/fs-policy/README.i18n.yaml +++ b/packages/fs/fs-policy/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/fs-policy/README.md -README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a -README.zh.md: aa0cb25899f5906ac9f531583ba48d01ad6095b4 +README.md: f6b3292bdc6e5565df0393a59c50d4e594921401 +README.zh.md: 2b30e6223719301df776b5d1cb7c674cb7ef7ff7 diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index dc4e937779..f6b3292bdc 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no #### What the model sees -This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown. +This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown. #### Token effect diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md index aa0cb25899..2b30e62237 100644 --- a/packages/fs/fs-policy/README.zh.md +++ b/packages/fs/fs-policy/README.zh.md @@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy) #### 模型看到的内容 -该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装;观察状态绝不会显示。 +该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。 #### Token 影响 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index fbe2e69043..8f462ed19b 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/README.md -README.md: a695d0ba8fb1d600689d2b68763e8423d1591da5 -README.zh.md: 5c600ab70b46da640637aec64efc1c0f0d0d54c0 +README.md: 246b1c8797e9a2ddc630724729edf8e2f1185bfc +README.zh.md: 6cfc3d750b0f6ffc9ee886f4d0f058885bc19083 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index a695d0ba8f..246b1c8797 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. +Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 5c600ab70b..6cfc3d750b 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。 +失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 951c0b7b57..fcd04cb17c 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -11,6 +11,7 @@ import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh- import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' +import { remediateFsError } from './error.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -116,10 +117,13 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot)) // Single-slot decision: the policy plugin returns { version: vObserved } or // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). - // No stat — the bare default never manufactures a version basis. - const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) + // No stat — the bare default never manufactures a version basis. The intent + // slot itself can throw FS_NOT_OBSERVED for an unread target, so it sits + // inside the try: both that refusal and the provider's guarded-mutation + // failure get the model-facing remedy below. let outcome try { + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) outcome = await ctx.fs.editText( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, @@ -128,8 +132,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { sandboxPolicy, ) } catch (error: unknown) { - // A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through. - throw sandbox.mapError(error, sandboxPolicy) + // A sandbox denial becomes the shared [sandbox: …] marker (the model + // recognizes it from bash); stale/not-observed failures gain their + // model-facing remedy; anything else passes through. + throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/src/error.ts b/packages/fs/tool-fs/src/error.ts new file mode 100644 index 0000000000..e67616887f --- /dev/null +++ b/packages/fs/tool-fs/src/error.ts @@ -0,0 +1,34 @@ +/** + * Model-facing remediation for guarded-mutation failures. The provider's + * `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but + * not the only correct recovery (re-read / read the file), so this package + * appends the remedy at the model boundary; provider messages stay + * machine-oriented and unchanged. + * @module @deepseek-ai/dsh-tool-fs/src/error + */ + +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsErrorCode } from '@deepseek-ai/dsh-fs' + +/** The remedy appended to each remediable failure code's message. */ +const REMEDIES: Partial<Record<FsErrorCode, string>> = { + FS_STALE_VERSION: 're-read the file, then retry', + FS_NOT_OBSERVED: 'read the file, then retry', +} + +/** + * Append the correct recovery instruction to a guarded-mutation failure's + * message. `FS_STALE_VERSION` (the file changed since this session's last + * observation, including a missing target) recovers only by re-reading; + * `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError` + * code is preserved so retry/permission/UI layers keep routing on it, and the + * original error chains as `cause`. Anything else passes through untouched. + * @param error - the caught value from a write/edit execution. + * @returns a remediated `FsError` for the two guarded-mutation codes, else the original value. + */ +export function remediateFsError(error: unknown): unknown { + if (!(error instanceof FsError)) return error + const remedy = REMEDIES[error.code] + if (!remedy) return error + return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error }) +} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 37a6d67e59..56e2be488b 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -12,6 +12,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' +import { remediateFsError } from './error.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -113,8 +114,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker (the model - // recognizes it from bash); any other error passes through. - throw sandbox.mapError(error, sandboxPolicy) + // recognizes it from bash); stale/not-observed failures gain their + // model-facing remedy; anything else passes through. + throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/tests/error.spec.ts b/packages/fs/tool-fs/tests/error.spec.ts new file mode 100644 index 0000000000..671eb32d9d --- /dev/null +++ b/packages/fs/tool-fs/tests/error.spec.ts @@ -0,0 +1,35 @@ +/** + * Unit tests for the model-facing error remediation: the remedy appended to + * guarded-mutation failures, code preservation, and passthrough behavior. + */ + +import { describe, expect, it } from 'vitest' +import { FsError } from '@deepseek-ai/dsh-fs' +import { remediateFsError } from '../src/error.ts' + +describe('remediateFsError', () => { + it('appends the re-read remedy to FS_STALE_VERSION, preserving the code and chaining the cause', () => { + const original = new FsError('cannot edit "x": file changed since it was read', 'FS_STALE_VERSION') + const remedied = remediateFsError(original) as FsError + expect(remedied).toBeInstanceOf(FsError) + expect(remedied.message).toBe('cannot edit "x": file changed since it was read — re-read the file, then retry') + expect(remedied.code).toBe('FS_STALE_VERSION') + expect(remedied.cause).toBe(original) + }) + + it('appends the read remedy to FS_NOT_OBSERVED', () => { + const remedied = remediateFsError(new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED')) as FsError + expect(remedied.message).toBe('edit requires reading "x" first — read the file, then retry') + expect(remedied.code).toBe('FS_NOT_OBSERVED') + }) + + it('leaves other FsError codes untouched', () => { + const original = new FsError('no match anywhere', 'FS_EDIT_NOT_FOUND') + expect(remediateFsError(original)).toBe(original) + }) + + it('leaves non-FsError values untouched', () => { + const original = new Error('boom') + expect(remediateFsError(original)).toBe(original) + }) +}) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index c835baebb9..3482b38569 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -71,6 +71,9 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('without reading it first') + expect(text(result)).toContain('read the file, then retry') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -89,6 +92,23 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') + }) + + it('the stale remedy is actionable: re-reading the changed file unblocks the retried write', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change + const stale = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(stale.isError).toBe(true) + expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Follow the remedy: re-read (refreshes the observed version), then retry. + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const retried = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(retried.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced') }) }) @@ -131,6 +151,9 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + // The policy's refusal reaches the model with the read remedy appended. + expect(text(result)).toContain('edit requires reading') + expect(text(result)).toContain('read the file, then retry') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) @@ -155,6 +178,23 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') + }) + + it('the stale remedy is actionable: re-reading the changed file unblocks the retried edit', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'hello brave world') // out-of-band change + const stale = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(stale.isError).toBe(true) + expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Follow the remedy: re-read (refreshes the observed version), then retry. + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const retried = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(retried.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello brave there') }) it('rejects an ambiguous match without replace_all', async () => { @@ -194,6 +234,43 @@ describe('default deployment (with dsh-fs-policy)', () => { }) }) + describe('deleted observed target (fail-closed corner)', () => { + it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await rm(join(dir, 'a.txt')) // out-of-band deletion + + // Edit of the missing target: stale (the missing-target path shares the + // stale code and the re-read remedy). + const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) + expect(edit.isError).toBe(true) + expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // Re-reading the missing file FAILS with FS_NOT_FOUND and records no + // observation, so the retried edit fails identically: the observed entry + // is never cleared for a deleted target. + const reread = await call('read', { file_path: 'a.txt' }) + expect(reread.isError).toBe(true) + expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) + const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) + expect(retriedEdit.isError).toBe(true) + expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // Write cannot recreate it either: the stale observation still forces + // replaceIfVersion, which rejects a missing target ("file no longer exists"). + const write = await call('write', { file_path: 'a.txt', content: 'fresh' }) + expect(write.isError).toBe(true) + expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // The dead end lifts once the file exists again and is freshly observed. + await writeFile(join(dir, 'a.txt'), 'restored') + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' }) + expect(recovered.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh') + }) + }) + describe('stat budget', () => { it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') @@ -264,6 +341,9 @@ describe('bare provider (no dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Even without policy, the stale text carries the re-read remedy. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') }) it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 914a1bf7de..ad01237c2b 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -397,12 +397,13 @@ describe('write tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('propagates a backend FsError as an isError result carrying its code', async () => { + it('propagates a backend FsError as an isError result carrying its code and remedy', async () => { const { ctx, fs } = await setup() fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION') const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } }) + expect(text(result)).toContain('re-read the file, then retry') }) }) From 044df0e0c8b30f1dab97db8e473317dc66b060c0 Mon Sep 17 00:00:00 2001 From: _Kerman <kermanx@qq.com> Date: Mon, 3 Aug 2026 19:49:38 +0800 Subject: [PATCH 049/190] docs(notes): record model-facing error remedy decision The tool-fs error wrapper decision: guarded-mutation failures gain their recovery instruction at the model boundary while the provider messages and structured codes stay unchanged; includes the deleted-target fail-closed corner. --- .../2026-08-03-fs-tool-error-remedy.i18n.yaml | 6 ++++ .../2026-08-03-fs-tool-error-remedy.md | 32 +++++++++++++++++++ .../2026-08-03-fs-tool-error-remedy.zh.md | 32 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml new file mode 100644 index 0000000000..98500c284e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.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-03-fs-tool-error-remedy.md +2026-08-03-fs-tool-error-remedy.md: f227c31365725652b130e097d70c79d3daab3684 +2026-08-03-fs-tool-error-remedy.zh.md: 11acd0cf48924833ced91591d5ea1424735969cd diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md new file mode 100644 index 0000000000..f227c31365 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md @@ -0,0 +1,32 @@ +# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary + +Status: implemented + +English | [中文](2026-08-03-fs-tool-error-remedy.zh.md) + +## Problem + +Guarded `write` and `edit` failures reach the model with messages that state the condition but not the only correct recovery: `FS_STALE_VERSION` ("file changed since it was read") and `FS_NOT_OBSERVED` ("edit requires reading … first"). The model must guess that the recovery is a re-read (or a first read) followed by a retry, and the retry/permission/UI layers that route on the structured code see the same message text. The provider-owned messages are part of the storage seam's machine-oriented vocabulary ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)), so the remedy cannot live there without leaking model-facing wording into every consumer of `FsError`. + +## Decision + +`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched: + +- `FS_STALE_VERSION` (including a missing edit target, which shares the stale code) gains `— re-read the file, then retry`. +- `FS_NOT_OBSERVED` gains `— read the file, then retry`. + +The structured `FsError` code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as `cause`. Provider messages stay machine-oriented and unchanged. + +In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording. + +## Alternatives considered + +- **Append the remedy to the provider messages in `dsh-fs` / `dsh-fs-local`.** Rejected because those messages are machine-oriented seam vocabulary consumed by retry, permission, and UI layers as well as the model surface; model-facing wording belongs at the model boundary, where `dsh-tool-fs` already owns result formatting ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)). +- **Add the recovery to prompt guidance instead.** Rejected because the failure arrives mid-task; a static instruction does not reliably reach the retry decision, while the error message is present exactly when the model must act. +- **Signal the remedy with a new `FsError` code.** Rejected because the two failures are the same conditions retry layers already handle; splitting the code would fork routing on identical semantics. + +## Consequences + +Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes. + +The remedy is not a promise: a deleted observed target cannot be unblocked, because re-reading a missing file fails with `FS_NOT_FOUND` and records no observation. That dead end is pinned fail-closed in the integration tests — the retried mutation fails identically until the target exists again and is freshly observed. diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md new file mode 100644 index 0000000000..11acd0cf48 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary + +Status: implemented + +[English](2026-08-03-fs-tool-error-remedy.md) | 中文 + +## Problem + +受防护的 `write` 与 `edit` 失败以只陈述条件、不给出唯一正确恢复方式的消息到达模型:`FS_STALE_VERSION`("file changed since it was read")与 `FS_NOT_OBSERVED`("edit requires reading … first")。模型必须自行猜测恢复方式是重新读取(或首次读取)后重试,而基于结构化错误码路由的重试/权限/UI 层看到的也是同一段消息文本。提供方拥有的消息属于存储接缝的面向机器词汇([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)),因此恢复指令不能放在那里,否则会把面向模型的措辞泄漏给 `FsError` 的每个消费者。 + +## Decision + +`dsh-tool-fs` 拥有一个面向模型的错误包装 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传: + +- `FS_STALE_VERSION`(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加 `— re-read the file, then retry`。 +- `FS_NOT_OBSERVED` 追加 `— read the file, then retry`。 + +结构化 `FsError` 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 `cause` 链入。提供方消息保持面向机器且不变。 + +在 `edit.ts` 中,`fs/edit-intent` waterfall 现在与提供方变更位于同一个 `try` 内,因此策略插件从 intent 槽抛出的 `FS_NOT_OBSERVED` 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型。 + +## Alternatives considered + +- **在 `dsh-fs` / `dsh-fs-local` 的提供方消息中追加恢复指令。** 被拒绝:这些消息是面向机器的接缝词汇,除模型表面外还被重试、权限与 UI 层消费;面向模型的措辞应位于模型边界,即 `dsh-tool-fs` 已经拥有结果格式化之处([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md))。 +- **改为在提示词引导中加入恢复方式。** 被拒绝:失败发生在任务中途;静态指令无法可靠地影响重试决策,而错误消息恰好在模型必须行动时出现。 +- **用新的 `FsError` 错误码表达恢复指令。** 被拒绝:这两种失败本就是重试层已处理的相同条件;拆分错误码会让语义相同的路由分叉。 + +## Consequences + +两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装器(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。 + +恢复指令不是承诺:已删除的观察目标无法被解除阻塞,因为重新读取缺失文件会以 `FS_NOT_FOUND` 失败且不记录观察。这一死胡同在集成测试中以 fail-closed 方式固定——在目标重新存在并被新鲜观察之前,重试的变更以相同方式失败。 From 0220e066332d4539472386458c6d5c0ae7785340 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 11:53:17 +0800 Subject: [PATCH 050/190] docs(tools): scope the Python snapshot obligation and language-neutral concurrency wording --- .../notes/implemented/feature/2026-06-15-code-mode.i18n.yaml | 4 ++-- .agents/notes/implemented/feature/2026-06-15-code-mode.md | 2 +- .agents/notes/implemented/feature/2026-06-15-code-mode.zh.md | 2 +- .../feature/2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../feature/2026-07-31-code-mode-language-dispatch.md | 4 +++- .../feature/2026-07-31-code-mode-language-dispatch.zh.md | 4 +++- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index d83737eb64..75b8ed0e80 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-code-mode.md -2026-06-15-code-mode.md: 31b39842bb20135517f41ced3f586d61454023e3 -2026-06-15-code-mode.zh.md: b524264e21a64fa719619e5ec3e7607c9592aa8d +2026-06-15-code-mode.md: 2bbd2357ce3ec19acac732c1f63a88d5b47dc3a8 +2026-06-15-code-mode.zh.md: 94ee9ae09763a7e8d6e27b3bed7b7a6443a55566 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md index 31b39842bb..2bbd2357ce 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md @@ -85,7 +85,7 @@ The worker runtime provides containment, not a security boundary: model code can ### What the model sees -The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under `Promise.all`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. +The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under the language's concurrency primitive (`Promise.all` in TypeScript, `asyncio.gather` in Python). The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index b524264e21..94ee9ae097 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -85,7 +85,7 @@ worker 运行时只能约束程序的运行,而不构成安全边界:模型 ### 模型看到的内容 -SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-code-mode-language-dispatch.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 +SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-code-mode-language-dispatch.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在该语言的并发原语(TypeScript 为 `Promise.all`,Python 为 `asyncio.gather`)下,调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 9c803c37ab..bffb432e93 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 1eadc05db9b95cd0365c124480e3977db4ede242 -2026-07-31-code-mode-language-dispatch.zh.md: 046456bfceb391a4771e61e431ff7182e7f9abdf +2026-07-31-code-mode-language-dispatch.md: e2d063eb5efc42f3079864479cf869ba4643bff1 +2026-07-31-code-mode-language-dispatch.zh.md: d911a43936cb0865533951de3dee845d135a22ca diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 1eadc05db9..e2d063eb5e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -35,4 +35,6 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri ## Consequences -Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend; the cost is that a `python` runtime cannot actually be exercised end to end until that backend ships, so this PR's coverage is unit-level (the renderer output and the dispatch/rejection paths) rather than a real Python run. +Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. + +The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 046456bfce..d911a43936 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -35,4 +35,6 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测;代价是在该后端发布前无法真正端到端跑一个 `python` 运行时,故本 PR 的覆盖是 unit 级(渲染器输出与分发/拒绝路径),而非真实的 Python 运行。 +新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 + +代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 From c836fcd416ddf0bc0c384fa24d6abbebdeb12c8d Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Wed, 5 Aug 2026 12:43:35 +0800 Subject: [PATCH 051/190] feat(telemetry): add feedback-gated OTEL modes --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 4 +- ...07-23-session-telemetry-otel-revival.zh.md | 4 +- .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 8 +- .../feature/2026-07-28-feedback-command.zh.md | 8 +- ...feedback-gated-session-telemetry.i18n.yaml | 6 + ...-08-05-feedback-gated-session-telemetry.md | 35 ++++ ...-05-feedback-gated-session-telemetry.zh.md | 35 ++++ docs/config-catalog.md | 16 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +- .../tests/fixtures/telemetry-otel-driver.ts | 10 ++ .../tests/fixtures/telemetry-otel.cordis.yml | 9 + examples/package.json | 2 + packages/feedback/README.i18n.yaml | 4 +- packages/feedback/README.md | 2 +- packages/feedback/README.zh.md | 2 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 4 +- .../feedback/command-feedback/README.zh.md | 4 +- packages/telemetry/README.i18n.yaml | 4 +- packages/telemetry/README.md | 6 +- packages/telemetry/README.zh.md | 6 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 16 +- .../session-telemetry-otel/README.zh.md | 16 +- .../session-telemetry-otel/package.json | 2 + .../session-telemetry-otel/src/index.ts | 94 +++++++---- .../session-telemetry-otel/src/invariant.ts | 7 +- .../tests/loader-composition.e2e.ts | 93 ++++++++--- .../session-telemetry-otel/tests/otel.spec.ts | 90 +++++++++- .../session-telemetry-otel/tsconfig.json | 3 + .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 11 +- .../telemetry/session-telemetry/README.zh.md | 11 +- .../session-telemetry/src/coordinator.ts | 158 ++++++++++++------ .../telemetry/session-telemetry/src/index.ts | 17 +- .../session-telemetry/tests/telemetry.spec.ts | 93 ++++++++++- pnpm-lock.yaml | 9 + 41 files changed, 635 insertions(+), 182 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md create mode 100644 .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index cd9e4f7e9f..3f487762d6 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md -2026-07-23-session-telemetry-otel-revival.md: a58598d8a956d47cb0cf6aa3e659f38314bc4b17 -2026-07-23-session-telemetry-otel-revival.zh.md: cc09717e349d5ae2ab5157bf46de30b1823c775f +2026-07-23-session-telemetry-otel-revival.md: dcbff9757cbb730b66f456535fbd7ae471b6ffd1 +2026-07-23-session-telemetry-otel-revival.zh.md: c3a098041795fa92bb4e0dd421ca09be94907cb8 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index a58598d8a9..dcbff9757c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -14,7 +14,7 @@ Every deployment that wants harness sessions in an observability stack must hand - **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. - **The `telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. -- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. +- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary. The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. @@ -34,4 +34,4 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` withholds records until feedback releases a prefix, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index cc09717e34..c3a0980417 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -14,7 +14,7 @@ Status: implemented - **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 - **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 -- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY` 与 `DISABLED` 投递模式,但未移动脱敏或后端边界。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -34,4 +34,4 @@ Status: implemented ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系。`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在反馈释放前暂存记录前缀,`DISABLED` 则不构造上报流水线;删除条目仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index 7a429953d8..be039deb2c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-feedback-command.md -2026-07-28-feedback-command.md: 1c093d0e37eb72dc66e3c5569bd642557dde56a1 -2026-07-28-feedback-command.zh.md: 300946a71ac7485a4bc787dd70ae5357147627f3 +2026-07-28-feedback-command.md: 963153ceb4332b74693ff5c1d248c616ff4e8de9 +2026-07-28-feedback-command.zh.md: 4dd02dcfb8d0606436c22e269db8c0d6cf163cee diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 1c093d0e37..963153ceb4 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -18,7 +18,7 @@ The package declares the log-only `feedback/record { text }` session event and e `dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends start persistence's ordinary eager drain; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. -Capture is deliberately inert: nothing in this repository reads `feedback/record`. +Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md). ### Why feedback owns an event @@ -34,7 +34,7 @@ Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /pla ### A new group -`packages/feedback/` is a new group because no existing one owns this. `goal/` is objective state, `session-title/` is titles, `core/` is the product spine. The group holds one package; a consumer would join it rather than forcing this one to grow. +`packages/feedback/` is a new group because no existing one owns this. `goal/` is objective state, `session-title/` is titles, `core/` is the product spine. The group holds one producer package; cross-cutting consumers stay in their owning groups rather than forcing this one to grow. ## Alternatives considered @@ -48,7 +48,7 @@ Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /pla **Register the command inside an existing package** such as `packages/ui/commands`. Avoids a new group and its README pair. Rejected: `ctx.commands` is the registry, not a home for arbitrary command implementations, and the requester asked for a standalone package. -**Parse structure out of the text** (category prefixes, severity markers). Rejected as speculative: no consumer exists to use the structure, and any control-word grammar makes the corresponding literal feedback unrecordable. Verbatim text is the widest surface a future consumer can narrow; a parsed one cannot be widened after the fact. +**Parse structure out of the text** (category prefixes, severity markers). Rejected as speculative: no consumer needs that structure, and any control-word grammar makes the corresponding literal feedback unrecordable. Verbatim text is the widest surface a future consumer can narrow; a parsed one cannot be widened after the fact. **Add a model-facing tool instead of a slash command.** Rejected: feedback is a direct human observation. Routing it through the model spends a turn, lets the model paraphrase the user's words, and makes the record contingent on the model choosing to call the tool. @@ -58,6 +58,6 @@ The TUI mounts the command unconditionally — no configuration, no dependency o The package owns one independent append-only event with no cross-event or mutable-data relation for an invariant companion to check. The event follows the session log's existing replay, fork, persistence, and crash-tail behavior. -Deferred: no consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. +Deferred: no product or model consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. The optional telemetry consumer treats the event only as an export-policy trigger. No snapshot accompanies this change. AGENTS.md asks for a keyless snapshot through a runnable example for product-user-visible behavior; this was skipped at the requester's explicit direction. The package tests plus a real Loader composition test over a `cordis.yml` are the whole of the evidence, alongside interactive verification in the assembled TUI. diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index 300946a71a..4dd02dcfb8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -18,7 +18,7 @@ Status: implemented `dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 -采集刻意不产生后续动作:本仓库中没有任何代码读取 `feedback/record`。 +采集对正在运行的 agent 与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为本地警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。 ### 为何反馈拥有自己的事件 @@ -34,7 +34,7 @@ Status: implemented ### 一个新的分组 -`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组目前只有一个包;未来的消费方应加入该分组,而不是迫使这个包不断膨胀。 +`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组只包含一个生产方包;跨领域的消费方留在各自所属的分组,而不是迫使这个包不断膨胀。 ## 考虑过的替代方案 @@ -48,7 +48,7 @@ Status: implemented **在现有包中注册该命令**,例如 `packages/ui/commands`。可省去新分组及其双语 README。已否决:`ctx.commands` 是注册表,而不是任意命令实现的归属地;且请求者明确要求独立的包。 -**从文本中解析结构**(类别前缀、严重程度标记)。已否决,属于投机设计:目前没有消费方使用该结构,而任何控制词语法都会让对应的字面反馈无法记录。原样文本是未来消费方可以收窄的最宽接口;而已被解析的接口无法事后放宽。 +**从文本中解析结构**(类别前缀、严重程度标记)。已否决,属于投机设计:没有消费方需要该结构,而任何控制词语法都会让对应的字面反馈无法记录。原样文本是未来消费方可以收窄的最宽接口;而已被解析的接口无法事后放宽。 **改为提供面向模型的工具。** 已否决:反馈是人类的直接观察。经由模型会消耗一个轮次、让模型改写用户的原话,并使记录取决于模型是否选择调用该工具。 @@ -58,6 +58,6 @@ TUI 无条件挂载该命令:没有配置,也不依赖 goal 栈。无头 CLI 本包拥有一个独立的仅追加事件,不存在跨事件关系或可变数据关系可供不变式伴生插件检查。该事件遵循会话日志现有的回放、fork、持久化和崩溃尾部行为。 -延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 +延期事项:没有产品或模型消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。可选的遥测消费方只将该事件作为导出策略触发器。 本次变更不附带 snapshot。AGENTS.md 要求面向产品用户的可见行为变更通过可运行示例附带无密钥 snapshot;此项按请求者的明确指示跳过。包测试连同一个基于真实 `cordis.yml` 的 Loader 组合测试即为全部证据,此外还有在组装后 TUI 中的交互验证。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml new file mode 100644 index 0000000000..d12ad78728 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.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-feedback-gated-session-telemetry.md +2026-08-05-feedback-gated-session-telemetry.md: 21a9028c603f3faaec39b2ddb8ef14644d6c84d4 +2026-08-05-feedback-gated-session-telemetry.zh.md: ea94c743b962a93a5fc64bdc2e4ed103aadecc99 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md new file mode 100644 index 0000000000..21a9028c60 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -0,0 +1,35 @@ +# Agent Note: Feedback-gated session telemetry + +Status: implemented + +English | [中文](2026-08-05-feedback-gated-session-telemetry.zh.md) + +## Problem + +Session telemetry originally has one mounted behavior: every accepted record enters the reporting backend immediately. Deployments need two stricter policies without replacing the plugin: hold a session's telemetry unless its user records feedback, or disable reporting while still explaining what happens to feedback. The policy must preserve the existing full-export default and the telemetry seam's redaction-before-backend boundary. + +## Decision + +`@deepseek-ai/dsh-session-telemetry-otel` exposes three uppercase `mode` values: + +- `FULL` is the default and preserves immediate delivery to the configured OTel pipeline. +- `FEEDBACK_ONLY` captures redacted copies in memory and releases the pending session prefix when `feedback/record` is appended. The released prefix includes the feedback event itself. Records appended after that event form another withheld prefix until another feedback event releases them. +- `DISABLED` constructs no exporter, processor, or logger provider. A `feedback/record` listener prints that nothing is shared and the feedback remains local. + +The generic telemetry coordinator owns the delivery distinction as `immediate` or `held`. Both paths project, clone, and run `telemetry/record` listeners at capture time. Immediate delivery sends the accepted record to the backend and advances the session's handoff cursor. Held delivery retains the accepted record per session without moving that cursor. `release(session)` submits the retained records in order, contains each backend failure independently, advances the cursor only for submitted records, and removes the released prefix. + +The OTel feedback listener is registered after the coordinator's session listener. Cordis therefore gives the coordinator the feedback append first, then the OTel listener releases a prefix that already contains that event. `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`; `DISABLED` does not validate or use exporter configuration. + +## Alternatives considered + +**Open a session permanently after its first feedback.** Rejected because later work would be shared without another feedback act and the plugin would need additional open-session state. Releasing one pending prefix per feedback has the smaller state machine and the narrower sharing boundary. + +**Buffer after `TelemetryCoordinator.emit()` in the OTel backend.** Rejected because the coordinator would advance its handoff cursor before a record became eligible for upload. A plugin rebuild would then lose the only retained copy and incorrectly treat the prefix as handed off. + +**Replay the canonical session log when feedback arrives.** Rejected because replay would repeat projection and redaction, exclude telemetry operation records that are not session events, and require more lifecycle state to distinguish previously released prefixes. + +**Use an unmounted plugin as the disabled state.** That remains the silent opt-out, but it cannot warn when feedback is recorded. The explicit disabled mode lets a deployment keep one configuration shape and communicate that the local feedback did not leave the process. + +## Consequences + +`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` retains deep-copied, already-redacted records in process memory until feedback or session collection; a crash before release uploads nothing from that prefix. A clean shutdown after the last feedback is part of the new withheld suffix, so feedback-only streams do not carry a reliable shutdown or crash signal. Each later feedback releases the suffix accumulated since the previous one. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md new file mode 100644 index 0000000000..ea94c743b9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -0,0 +1,35 @@ +# Agent Note:反馈门控的会话遥测 + +Status: implemented + +[English](2026-08-05-feedback-gated-session-telemetry.md) | 中文 + +## 问题 + +会话遥测原本只有一种已挂载行为:每条已接受记录都立即进入上报后端。部署方需要两种更严格的策略,且不替换插件:只有用户记录反馈时才释放该会话的遥测,或禁用上报并仍向用户说明反馈的去向。该策略必须保留现有的全量导出默认值,以及遥测 seam 在记录抵达后端之前脱敏的边界。 + +## 决策 + +`@deepseek-ai/dsh-session-telemetry-otel` 公开三个大写的 `mode` 值: + +- `FULL` 是默认值,保留向已配置 OTel 流水线的即时投递。 +- `FEEDBACK_ONLY` 在内存中捕获已脱敏副本,并在追加 `feedback/record` 时释放待处理的会话前缀。已释放前缀包含反馈事件本身。在该事件之后追加的记录会形成另一个暂存前缀,直到下一个反馈事件将其释放。 +- `DISABLED` 不构造导出器、处理器或日志提供方。`feedback/record` 监听器会输出警告,说明什么都不会共享,且反馈仍留在本地。 + +通用遥测协调器以 `immediate` 或 `held` 的形式拥有这两种投递方式。两条路径都会在捕获时进行投影、深拷贝,并运行 `telemetry/record` 监听器。即时投递把已接受记录发送到后端,并推进会话的 handoff 游标。暂存投递按会话保留已接受记录,且不移动该游标。`release(session)` 按顺序提交保留的记录,独立隔离每个后端失败,仅为已提交的记录推进游标,并移除已释放前缀。 + +OTel 反馈监听器在协调器的会话监听器之后注册。因此,Cordis 先将反馈追加交给协调器,再由 OTel 监听器释放已包含该事件的前缀。`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填;`DISABLED` 不校验也不使用导出器配置。 + +## 考虑过的替代方案 + +**会话在首次反馈后永久开放。** 已否决,因为后续工作会在用户未再次提交反馈的情况下被共享,而且插件需要额外的会话开放状态。每次反馈只释放一个待处理前缀,状态机更小,共享边界也更窄。 + +**在 OTel 后端的 `TelemetryCoordinator.emit()` 之后缓冲。** 已否决,因为协调器会在记录具备上传资格前推进 handoff 游标。插件重建后,唯一保留的副本会丢失,而协调器会错误地将该前缀视为已交接。 + +**反馈到达时回放权威会话日志。** 已否决,因为回放会重复执行投影与脱敏,排除不属于会话事件的遥测运维记录,且需要更多生命周期状态才能区分已释放前缀。 + +**以不挂载插件表示禁用状态。** 这仍然是静默退出方式,但无法在记录反馈时输出警告。显式禁用模式让部署方可以保持同一种配置形态,并说明本地反馈未离开进程。 + +## 后果 + +`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 会在进程内存中保留已深拷贝且已脱敏的记录,直到收到反馈或会话被回收;释放前发生崩溃时,该前缀不上传任何内容。上次反馈之后的干净关闭属于新的暂存后缀,因此仅反馈的流不携带可靠的关闭或崩溃信号。每个后续反馈都会释放从上一个反馈开始累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f552e6ab63..0c4fb632c1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1159,12 +1159,13 @@ Requires: `sessions` ```ts config-catalog /** - * Plugin configuration: two verbatim SDK option shapes plus nothing else. - * `exporter.url` is the one field this package validates itself — required, - * no default, must parse as an `http(s)` URL — because a missing endpoint - * must fail at plugin load, not at first export. + * Plugin configuration: one sharing policy plus two verbatim SDK option + * shapes. `exporter.url` is required for modes that upload and unused for + * `DISABLED`. */ export interface Config { + /** Sharing policy; defaults to immediate `FULL` delivery. */ + mode?: TelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, @@ -1172,7 +1173,7 @@ export interface Config { * is the one field this package requires and validates itself. */ exporter?: OTLPExporterNodeConfigBase & { - /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string } /** @@ -1181,11 +1182,14 @@ export interface Config { */ processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'> } + +/** Session-sharing policy selected by {@link Config.mode}. */ +export type TelemetryMode = typeof TELEMETRY_MODES[number] ``` Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:54`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 54291934fd..d159fa0a53 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -853,7 +853,7 @@ Transform one outbound record before it reaches the backend. This waterfall is t 'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:42`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 395d0850e1..e051463877 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1996,7 +1996,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:140`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fabd16bbdd..4ccc19f305 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -33,7 +33,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | @@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../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/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`) | - | +| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:42`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts index 02be1a9011..72305f0724 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -11,6 +11,7 @@ import { createServer } from 'node:http' import { once } from 'node:events' import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' +import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' const configPath = process.argv[2] if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path') @@ -35,6 +36,15 @@ try { // The fixture credential rides the model-visible user message; the exported // copy must scrub it while the canonical log keeps the original bytes. await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) + const mode = process.env.DSH_TELEMETRY_E2E_MODE ?? 'FULL' + if (mode !== 'FULL') { + const [agent] = ctx.get('agents')?.roots() ?? [] + if (agent === undefined) throw new Error('telemetry-otel driver requires one root agent') + recordFeedback(agent.session, 'fixture feedback') + if (mode === 'FEEDBACK_ONLY') { + await runOneShot(ctx, { task: 'post-feedback private suffix' }) + } + } } finally { await ctx.fiber.dispose() } diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index 34e23b828e..1433173768 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -2,6 +2,14 @@ # path, exporting to the mock OTLP collector the driver starts (url via env). # The redact-rule entry models a deployment mounting its own scrub rule on the # telemetry/record waterfall — the seam itself ships no rules. +- id: logger-console + name: '@cordisjs/plugin-logger-console' + config: + colors: false + levels: + default: 3 + showTime: '' + - id: cli-mock-llm name: './cli-mock-llm.ts' @@ -14,6 +22,7 @@ - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: + mode: !!js process.env.DSH_TELEMETRY_E2E_MODE || 'FULL' exporter: url: !!js process.env.DSH_TELEMETRY_E2E_URL diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..0298685693 100644 --- a/examples/package.json +++ b/examples/package.json @@ -7,6 +7,7 @@ "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-logger-console": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", @@ -14,6 +15,7 @@ "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", + "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml index 31ed2d25e8..4ad5a93fb5 100644 --- a/packages/feedback/README.i18n.yaml +++ b/packages/feedback/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/feedback/README.md -README.md: 7962a16ee9bc7d8a969a466591d761829cd55d7f -README.zh.md: aad8f4d797ff16a5ef9be4c968fb28d708bad13e +README.md: d2a4a5a27e1c661d2f62b328578fd890a0c622ee +README.zh.md: 2fa42e3bb5f05dfc425356f302f44e497b100f24 diff --git a/packages/feedback/README.md b/packages/feedback/README.md index 7962a16ee9..d2a4a5a27e 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -8,4 +8,4 @@ The feedback family lets a human record a remark about the session without actin |---|---|---| | `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — | -A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads `feedback/record` events from the session log rather than changing how they are captured. +A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../telemetry/session-telemetry-otel/) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md index aad8f4d797..2fa42e3bb5 100644 --- a/packages/feedback/README.zh.md +++ b/packages/feedback/README.zh.md @@ -8,4 +8,4 @@ feedback 家族让人类记录对会话的评价,但不据此采取任何动 |---|---|---| | `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | -被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取 `feedback/record` 事件,而不是改变它们的采集方式。 +被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史。挂载后,[`dsh-session-telemetry-otel`](../telemetry/session-telemetry-otel/) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index 47c169ec3f..ea439ce2fe 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: c9650d6a2c595550545b3dbf07f62e6aa65f39b9 -README.zh.md: ba24276ba1bd71a4eb68c7fdb48a3760bdbec8fc +README.md: e3b0e58f1746c7bcd1c74ac0990a872a1f24d7d7 +README.zh.md: 40ec871caff6f90b0b1c685e833c874e32a48d16 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index c9650d6a2c..e3b0e58f17 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -15,7 +15,7 @@ Surrounding whitespace is discarded, but feedback is otherwise unparsed: no trun ## What this plugin does and does not do -`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer, starts no model work, and no plugin in this repository reads the event. +`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../telemetry/session-telemetry-otel/) consumer observes the event without changing its capture contract. The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../ui/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`. @@ -52,7 +52,7 @@ Independent of the model request path. Recording appends to the session log only ## Known Limitations and Deferred Work -- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads `feedback/record`; a consumer is a separate package. +- **No feedback retrieval or management surface** — the optional OTel plugin uses the event only as a sharing trigger. There is no retrieval, aggregation, categorization, or model-facing tool for `feedback/record`. - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index ba24276ba1..40ec871caf 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -15,7 +15,7 @@ ## 本插件做什么、不做什么 -`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,不启动任何模型工作;本仓库中也没有任何插件读取该事件。 +`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../telemetry/session-telemetry-otel/) 消费方会观察该事件,但不改变它的采集契约。 反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../ui/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`。 @@ -52,7 +52,7 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal ## 已知限制与暂缓工作 -- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取 `feedback/record`;消费方是另一个独立包。 +- **没有反馈检索或管理 surface**:可选的 OTel 插件仅将该事件用作共享触发器。本包不为 `feedback/record` 提供检索、聚合、分类或面向模型的工具。 - **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 - **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 - **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 diff --git a/packages/telemetry/README.i18n.yaml b/packages/telemetry/README.i18n.yaml index 41f1bd956f..cd3be8d155 100644 --- a/packages/telemetry/README.i18n.yaml +++ b/packages/telemetry/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/telemetry/README.md -README.md: 944cb3f9bac6169feddf8b49bc481cfbe7c6fa9d -README.zh.md: 795b20abb47e1bf791730cc7f3ebb0522549a271 +README.md: 0adf140a19bd6ab19c4d4139d4ebdae941c0d1b0 +README.zh.md: 57988732e36d105ebcc48adcdab9344a6cccb525 diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index 944cb3f9ba..0adf140a19 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/record` waterfall (deployment-mounted redaction rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The boundary axiom, redaction waterfall, fixed chunk projection, handoff cursor, and operational-record channel are pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); immediate, feedback-gated, and disabled delivery are owned by [the mode decision](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md). | Package | Role | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | -| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. | +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, immediate or held handoff, cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | +| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: `FULL`, `FEEDBACK_ONLY`, or `DISABLED` policy around the OTel JS SDK log pipeline. | diff --git a/packages/telemetry/README.zh.md b/packages/telemetry/README.zh.md index 795b20abb4..57988732e3 100644 --- a/packages/telemetry/README.zh.md +++ b/packages/telemetry/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。整套设计固定在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中:边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/record` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。 +面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。边界公理、脱敏 waterfall(瀑布式事件)、固定分片投影、handoff 游标及运维记录通道的决定见[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md);即时、反馈门控及禁用投递由[模式决策](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)统一规定。 | 包(package) | 职责 | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、handoff 游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | -| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:OTel JS SDK 的日志流水线(`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP 导出器),经透传(passthrough)原样配置。 | +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、即时或暂存交接、游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | +| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:围绕 OTel JS SDK 日志流水线实施 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 策略。 | diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index b1a2052a3f..6557557b8c 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md -README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db -README.zh.md: 76de1bf1ad58a0239907f3b63c672177874c7966 +README.md: fab2461477b2174bded42ed6f05ae55c7c5f697c +README.zh.md: ab0191188836e03434adbce527d31b62ead848a3 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 9b208e291e..fab2461477 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. +The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam hands records over immediately, releases them only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. ## Config @@ -10,6 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: + mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter url: https://collector.example.com/v1/logs headers: @@ -17,15 +18,21 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load (as does a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown). Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. +| `mode` | Behavior | +|---|---| +| `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. | +| `FEEDBACK_ONLY` | Each `feedback/record` releases the redacted, projected session prefix through that event. Later records wait for another feedback event and remain local if none arrives. | +| `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. | + +`exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. ## What leaves the machine -Records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. +In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend. ## Field mapping -Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. One consequence of continuing rather than replaying: a turn left open mid-stream and never closed marks the previous process dying inside it. The local log is repaired with synthetic closers at resume, but those repairs are never exported — the wire stream stays faithful to what the crashed process actually shipped, and a later clean `shutdown` marker attests only to the resumed process's own exit. +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)` and alert on severity. In `FULL`, they may also detect crashes by `shutdown`-record absence: the marker is emitted at the session's own disposal or application teardown, and a marker followed by more events is a telemetry reload. In `FEEDBACK_ONLY`, a released prefix normally has no later `shutdown` marker, so its absence is not a crash signal. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. A resumed local log may contain synthetic closers that were never exported; the wire stream stays faithful to records actually handed to the SDK. ## Model Experience @@ -39,3 +46,4 @@ None; this package neither assembles nor sends a provider request. - **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. - **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory. +- **Feedback-only memory** — each session retains deep-copied, redacted projected records in memory until feedback releases them or the session becomes unreachable. There is no durable pre-feedback spool; a crash before feedback uploads nothing. diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 76de1bf1ad..ab01911888 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。它原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 +[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是立即交接记录、仅在记录反馈时释放记录,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 ## 配置 @@ -10,6 +10,7 @@ - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: + mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter url: https://collector.example.com/v1/logs headers: @@ -17,15 +18,21 @@ processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` 是本包(package)唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败(`processor.maxExportBatchSize` 不是正整数时同样如此:SDK 会接受该值,随后却在关闭时因它挂起)。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 +| `mode` | 行为 | +|---|---| +| `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK,包括生命周期运维记录。 | +| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会释放截至该事件的已脱敏、已投影会话前缀。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | +| `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 | + +`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`,SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。 ## 哪些数据会离开本机 -记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。 +在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。 ## 字段映射 -seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用关闭时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。继续而非回放的一个后果:流中一个开启后再未关闭的轮次,标志着上一个进程死在了该轮次之内。恢复时本地日志会以合成的关闭事件修复,但这些修复绝不导出:导出的流忠实于崩溃进程实际发出的内容,其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。 +seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重,并按严重级别告警。在 `FULL` 中,接收端还可通过缺少 `shutdown` 记录检测崩溃:该标记在会话自身 dispose(资源释放)或应用关闭时发出;标记之后出现更多事件,说明遥测发生了重载。在 `FEEDBACK_ONLY` 中,已释放的前缀通常不包含随后的 `shutdown` 标记,因此缺少该标记不是崩溃信号。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话的流从继承边界开始,其前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。恢复后的本地日志可能包含从未导出的合成关闭事件;协议流忠实于实际交给 SDK 的记录。 ## 模型体验 @@ -39,3 +46,4 @@ seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`; - **上游实验性源码树**:`@opentelemetry/sdk-logs` 仍从上游实验性(experimental)源码树发布;SDK API 的变动只会落在本包,也仅落在本包;seam 契约不动。 - **无真实 collector 覆盖**:所有测试都导出到本地 mock collector;无密钥的 Loader 组合 e2e(`tests/loader-composition.e2e.ts`)在每次运行中都覆盖协议格式(wire format)形态,而面对真实 OTLP 部署的行为(认证、TLS、限流)属于 SDK 导出器文档的职责范围。 +- **仅反馈模式的内存占用**:每个会话都会在内存中保留已深拷贝、已脱敏的投影记录,直到反馈将其释放或会话变得不可达。反馈前不存在持久化 spool;如果在反馈前崩溃,则什么都不上传。 diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json index 7be8c04ce4..4037cfe28a 100644 --- a/packages/telemetry/session-telemetry-otel/package.json +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -36,6 +36,7 @@ "schemastery": "^3.18.0" }, "peerDependencies": { + "@deepseek-ai/dsh-command-feedback": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -44,6 +45,7 @@ }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 85dd75f275..cb0ee71fc7 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -6,8 +6,8 @@ * record handed over by the seam onto `logger.emit()`. Per the seam's * boundary axiom, everything downstream of that call (batching, retry, * queueing, loss policy) is the SDK's documented behavior, configured - * verbatim through the `exporter`/`processor` passthroughs; this package - * adds no knobs of its own on top of them. + * verbatim through the `exporter`/`processor` passthroughs. This package owns + * only whether capture is immediate, feedback-released, or disabled. * * @module @deepseek-ai/dsh-session-telemetry-otel */ @@ -15,7 +15,14 @@ import { createRequire } from 'node:module' import z from 'schemastery' import type { Context } from 'cordis' -import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry' +import type {} from '@deepseek-ai/dsh-command-feedback' +import { + Telemetry, + TelemetryCoordinator, + type TelemetryDelivery, + type TelemetryRecord, + type TelemetrySeverity, +} from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' import { BatchLogRecordProcessor, @@ -31,13 +38,22 @@ import { resourceFromAttributes } from '@opentelemetry/resources' // version (same pattern as dsh-llm's attribution identity). const { version } = createRequire(import.meta.url)('../package.json') as { version: string } +/** Supported session-sharing policies for the OTel backend. */ +export const TELEMETRY_MODES = ['FULL', 'FEEDBACK_ONLY', 'DISABLED'] as const + +/** Session-sharing policy selected by {@link Config.mode}. */ +export type TelemetryMode = typeof TELEMETRY_MODES[number] + +const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' + /** - * Plugin configuration: two verbatim SDK option shapes plus nothing else. - * `exporter.url` is the one field this package validates itself — required, - * no default, must parse as an `http(s)` URL — because a missing endpoint - * must fail at plugin load, not at first export. + * Plugin configuration: one sharing policy plus two verbatim SDK option + * shapes. `exporter.url` is required for modes that upload and unused for + * `DISABLED`. */ export interface Config { + /** Sharing policy; defaults to immediate `FULL` delivery. */ + mode?: TelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, @@ -45,7 +61,7 @@ export interface Config { * is the one field this package requires and validates itself. */ exporter?: OTLPExporterNodeConfigBase & { - /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string } /** @@ -57,13 +73,14 @@ export interface Config { /** * Schemastery validator for {@link Config}; cordis runs it before the plugin - * starts. Shape-level only — the load-bearing `exporter.url` check lives in - * the constructor so its error message names the field. Both slots are opaque - * passthroughs: the SDK owns their shapes and validates its own options; - * re-declaring them field-by-field here would violate the boundary axiom - * (and silently drop every field not re-declared). + * starts. Shape-level only — the mode-dependent `exporter.url` check lives in + * the constructor so its error message names the field. Both SDK slots are + * opaque passthroughs: the SDK owns their shapes and validates its own + * options; re-declaring them field-by-field here would violate the boundary + * axiom (and silently drop every field not re-declared). */ export const Config: z<Config> = z.object({ + mode: z.union(TELEMETRY_MODES).default('FULL'), exporter: z.any(), processor: z.any(), }) @@ -76,22 +93,32 @@ const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; seve } /** - * The backend plugin — the only entry a deployment loads. Constructing it - * wires the SDK pipeline, registers the `telemetry` service (duplicate load - * throws, cordis' standard duplicate-service behavior), and composes the - * seam's {@link TelemetryCoordinator}, which installs the capture side onto - * this fiber. + * The backend plugin — the only entry a deployment loads. It always registers + * the `telemetry` service (duplicate load throws). Uploading modes wire the SDK + * pipeline and compose {@link TelemetryCoordinator}; `DISABLED` constructs no + * SDK state and listens only to warn when recorded feedback stays local. */ export class TelemetryOtel extends Telemetry { static inject = ['sessions'] static Config = Config - private readonly provider: LoggerProvider - private readonly ledger: Logger - private readonly ops: Logger + private readonly provider: LoggerProvider | undefined + private readonly ledger: Logger | undefined + private readonly ops: Logger | undefined constructor(ctx: Context, config: Config) { super(ctx) + const mode = config.mode ?? 'FULL' + if (mode === 'DISABLED') { + this.provider = undefined + this.ledger = undefined + this.ops = undefined + ctx.on('session/event', (_session, event) => { + if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING) + }) + return + } + const url = config.exporter?.url if (url === undefined || url.length === 0) { throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)') @@ -134,16 +161,26 @@ export class TelemetryOtel extends Telemetry { }) this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) - new TelemetryCoordinator(ctx, this) + const delivery: TelemetryDelivery = mode === 'FULL' ? 'immediate' : 'held' + const coordinator = new TelemetryCoordinator(ctx, this, delivery) + if (mode === 'FEEDBACK_ONLY') { + // The coordinator listener is registered first, so a feedback event + // enters the held prefix before this listener releases that exact prefix. + ctx.on('session/event', (session, event) => { + if (event.type === 'feedback/record') coordinator.release(session) + }) + } } /** * Map one seam record onto the SDK logger for its channel — a synchronous - * enqueue into the batch processor's queue. + * enqueue into the batch processor's queue. Direct calls are no-ops in + * `DISABLED`, where no coordinator or SDK pipeline exists. * @param record - the logical record handed over by the coordinator. */ emit(record: TelemetryRecord): void { const logger = record.channel === 'ops' ? this.ops : this.ledger + if (logger === undefined) return logger.emit({ timestamp: record.time, observedTimestamp: record.time, @@ -167,14 +204,15 @@ export class TelemetryOtel extends Telemetry { /** * Delegate disposal to the SDK's shutdown contract: drain the queue and * quiesce. With no concurrent `forceFlush()` in the process (see above), - * shutdown's internal drain is complete — everything emitted before this - * call, including the coordinator's dispose-time `shutdown` markers, is - * exported before the exporter closes. Awaited (and error-contained) by - * the coordinator's disposer. + * shutdown's internal drain is complete — everything handed to the SDK + * before this call is exported before the exporter closes. In `FULL`, that + * includes dispose-time `shutdown` markers; held suffixes never reach the + * SDK. Awaited (and error-contained) by the coordinator's disposer. A + * disabled backend resolves immediately. * @returns resolves when the SDK pipeline has quiesced. */ shutdown(): Promise<void> { - return this.provider.shutdown() + return this.provider === undefined ? Promise.resolve() : this.provider.shutdown() } } diff --git a/packages/telemetry/session-telemetry-otel/src/invariant.ts b/packages/telemetry/session-telemetry-otel/src/invariant.ts index 075e5cc193..030b7ce670 100644 --- a/packages/telemetry/session-telemetry-otel/src/invariant.ts +++ b/packages/telemetry/session-telemetry-otel/src/invariant.ts @@ -15,10 +15,9 @@ export const name = 'session-telemetry-otel-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the backend forwards seam records into the OTel SDK's - * in-process pipeline and appends nothing to any session; its only observable - * effects (batching, export) happen inside the SDK past the seam's boundary - * axiom, out of reach of an independent companion. + * No runtime invariant: mode selection changes capture handoff, SDK setup, and + * local diagnostics without mutating session or service state an independent + * companion can compare. Export remains inside the SDK past the seam boundary. */ const install: InvariantInstaller = () => {} diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts index 8f16662614..e07e05fed9 100644 --- a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -40,6 +40,11 @@ interface OtlpCapture { }[] } +interface FixtureOutput { + captures: OtlpCapture[] + logContent: string +} + async function jsonlFiles(dir: string): Promise<string[]> { const entries = await readdir(dir, { withFileTypes: true }) const paths = await Promise.all(entries.map(async (entry) => { @@ -50,10 +55,29 @@ async function jsonlFiles(dir: string): Promise<string[]> { return paths.flat() } +async function readFixtureOutput(cwd: string): Promise<FixtureOutput> { + const captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[] + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + return { captures, logContent: await readFile(logs[0] as string, 'utf8') } +} + +function allRecords(captures: OtlpCapture[]) { + return captures.flatMap(capture => capture.resourceLogs.flatMap(resource => + resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record }))))) +} + +function eventTypes(captures: OtlpCapture[]): string[] { + return allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' + ? [attribute.value['stringValue']] + : []) ?? []) +} + describe('session-telemetry-otel through a real headless cordis.yml', () => { it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => { - let captures: OtlpCapture[] = [] - let logContent = '' + let output!: FixtureOutput const { stderr } = await runLoaderSmoke({ label: 'session-telemetry-otel loader smoke', tempDirPrefix: 'telemetry-otel-e2e-', @@ -61,39 +85,70 @@ describe('session-telemetry-otel through a real headless cordis.yml', () => { libBinScript: driver, configPath, tsconfigPath: repoTsconfig, - inspect: async (cwd) => { - captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[] - const logs = await jsonlFiles(join(cwd, '.sessions')) - expect(logs).toHaveLength(1) - logContent = await readFile(logs[0] as string, 'utf8') - }, + inspect: async (cwd) => { output = await readFixtureOutput(cwd) }, }) expect(stderr).not.toContain('UNHANDLED') - const records = captures.flatMap(capture => capture.resourceLogs.flatMap(resource => - resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record }))))) + const records = allRecords(output.captures) expect(records.length).toBeGreaterThan(0) - const eventTypes = records.flatMap(({ record }) => - record.attributes?.flatMap(attribute => - attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' - ? [attribute.value['stringValue']] - : []) ?? []) + const types = eventTypes(output.captures) for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) { - expect(eventTypes, expected).toContain(expected) + expect(types, expected).toContain(expected) } expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true) // The deployment-mounted rule on the wire: the fixture credential never // leaves the process, its surrounding prose does, and the placeholder // marks the spot — the seam itself ships no rules. - const wire = JSON.stringify(captures) + const wire = JSON.stringify(output.captures) expect(wire).not.toContain(FIXTURE_SECRET) expect(wire).toContain(FIXTURE_PLACEHOLDER) expect(wire).toContain('prove telemetry with key') // The canonical session log is never rewritten. - expect(logContent).toContain(FIXTURE_SECRET) - expect(logContent).not.toContain(FIXTURE_PLACEHOLDER) + expect(output.logContent).toContain(FIXTURE_SECRET) + expect(output.logContent).not.toContain(FIXTURE_PLACEHOLDER) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('exports only prefixes ending in feedback under feedback-only mode', async () => { + let output!: FixtureOutput + const { stderr } = await runLoaderSmoke({ + label: 'session-telemetry-otel feedback-only loader smoke', + tempDirPrefix: 'telemetry-otel-feedback-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { DSH_TELEMETRY_E2E_MODE: 'FEEDBACK_ONLY' }, + inspect: async (cwd) => { output = await readFixtureOutput(cwd) }, + }) + expect(stderr).not.toContain('UNHANDLED') + + const wire = JSON.stringify(output.captures) + expect(eventTypes(output.captures)).toContain('feedback/record') + expect(wire).toContain('fixture feedback') + expect(wire).toContain('prove telemetry with key') + expect(wire).not.toContain('post-feedback private suffix') + expect(output.logContent).toContain('post-feedback private suffix') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps disabled feedback local and prints the stable warning', async () => { + let output!: FixtureOutput + const { stdout } = await runLoaderSmoke({ + label: 'session-telemetry-otel disabled loader smoke', + tempDirPrefix: 'telemetry-otel-disabled-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { DSH_TELEMETRY_E2E_MODE: 'DISABLED' }, + inspect: async (cwd) => { output = await readFixtureOutput(cwd) }, + }) + + expect(output.captures).toEqual([]) + expect(output.logContent).toContain('fixture feedback') + expect(stdout.match(/session telemetry is DISABLED; nothing will be shared and this feedback remains local/)?.[0]) + .toMatchInlineSnapshot('"session telemetry is DISABLED; nothing will be shared and this feedback remains local"') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index cccb90ed43..18c466f7aa 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -5,12 +5,13 @@ * for the default-exported Service class. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createServer, type Server } from 'node:http' import { once } from 'node:events' import { gunzipSync } from 'node:zlib' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TelemetryOtel, { Config } from '../src/index.ts' @@ -30,6 +31,7 @@ interface OtlpLogsRequest { severityNumber: number severityText: string attributes?: { key: string; value: Record<string, unknown> }[] + body?: unknown }[] }[] }[] @@ -88,6 +90,14 @@ function allRecords(captures: Capture[]) { s.logRecords.map(record => ({ scope: s.scope.name, record }))))) } +function eventTypes(captures: Capture[]): string[] { + return allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' + ? [attribute.value['stringValue']] + : []) ?? []) +} + describe('TelemetryOtel wire', () => { it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => { const { url, captures } = await mockCollector() @@ -195,6 +205,82 @@ describe('TelemetryOtel wire', () => { r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start')) expect(start?.record.severityNumber).toBe(13) }) + + it('holds each session suffix until the next feedback event', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + mode: 'FEEDBACK_ONLY', + exporter: { url }, + }) + const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + recordFeedback(session, 'first report') + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + recordFeedback(session, 'second report') + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + + const types = allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' ? [attribute.value.stringValue] : []) ?? []) + expect(types).toEqual(['turn/start', 'feedback/record', 'turn/end', 'feedback/record']) + expect(JSON.stringify(captures)).toContain('first report') + expect(JSON.stringify(captures)).toContain('second report') + expect(allRecords(captures).some(({ scope }) => scope.endsWith('/ops'))).toBe(false) + }) + + it('sends no request when feedback-only mode ends without feedback', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + mode: 'FEEDBACK_ONLY', + exporter: { url }, + }) + const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + expect(captures).toEqual([]) + }) + + it('boots disabled without exporter config and warns when feedback stays local', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const fiber = await ctx.plugin(TelemetryOtel, { mode: 'DISABLED' }) + const session = ctx.sessions.create(SessionId('disabled'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + recordFeedback(session, 'local report') + + expect(warn).toHaveBeenCalledWith( + 'session telemetry is DISABLED; nothing will be shared and this feedback remains local', + ) + ctx.telemetry.emit({ + channel: 'ledger', + time: 0, + severity: 'info', + attributes: {}, + body: null, + }) + await ctx.telemetry.shutdown() + await fiber.dispose() + recordFeedback(session, 'after disposal') + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('defaults direct construction to full delivery', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + new TelemetryOtel(ctx, { exporter: { url } }) + const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await ctx.fiber.dispose() + + expect(eventTypes(captures)).toContain('turn/start') + }) }) describe('TelemetryOtel config fails loud', () => { @@ -203,6 +289,8 @@ describe('TelemetryOtel config fails loud', () => { [{ exporter: { url: '' } }, /exporter\.url is required/], [{ exporter: { url: 'not a url' } }, /not a valid URL/], [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], + [{ mode: 'FEEDBACK_ONLY' }, /exporter\.url is required/], + [{ mode: 'INVALID' }, /INVALID/], // The SDK accepts a non-positive batch size but its shutdown drain then // splices empty batches forever — dispose would hang, so reject at load. [{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/], diff --git a/packages/telemetry/session-telemetry-otel/tsconfig.json b/packages/telemetry/session-telemetry-otel/tsconfig.json index 9512133cf7..4ba93f9eb1 100644 --- a/packages/telemetry/session-telemetry-otel/tsconfig.json +++ b/packages/telemetry/session-telemetry-otel/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/session" }, + { + "path": "../../feedback/command-feedback" + }, { "path": "../../llm/llm" }, diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 18f6751424..da3a62e2fd 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/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/telemetry/session-telemetry/README.md -README.md: 272c9abe78849be3d2bba2c54cd7e25bcbe2d4c2 -README.zh.md: e6f077c1d12d00e746147908560d05381fde11c3 +README.md: d38433a728c699c7fb3cc0512bb6a2d977dd4cc6 +README.zh.md: 3a86b01321fc7dfd33d39530ee7fa38a6ee1f2dc diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 272c9abe78..d38433a728 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -2,23 +2,23 @@ English | [中文](README.zh.md) -The telemetry seam: the CAPTURE side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +The telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can hand each redacted record over immediately or hold a per-session prefix for an explicit release. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) and [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md). ## The backend contract -`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor. +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path, either at capture or held-prefix release), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `immediate` delivery or `held` delivery and calls `release(session)` at its owning trigger. ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off or hold; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). Immediate delivery hands lifecycle records over; held delivery leaves any suffix after the last release local, including its later shutdown marker. ## The redact waterfall -Every record passes the `telemetry/record` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/record` waterfall immediately after projection — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Held delivery stores only the waterfall result, so later policy removal cannot expose the original capture. Redaction applies to the outbound copy only; the canonical session log is never rewritten. ## The handoff cursor -A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. +A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Immediate delivery advances it at capture; held delivery advances it only when `release(session)` hands that record to the backend. An unreleased prefix therefore survives a coordinator reload through deterministic re-adoption instead of disappearing with its in-memory copy. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. ## The fixed chunk projection @@ -40,3 +40,4 @@ None; this package neither assembles nor sends a provider request. - **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). - **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. +- **Held prefixes duplicate memory** — held delivery retains one deep-copied, redacted record per projected event until release or session collection. It adds no durable outbox and intentionally trades memory for a simple no-upload-before-trigger boundary. diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index e6f077c1d1..3a86b01321 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -2,23 +2,23 @@ [English](README.md) | 中文 -遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 +遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。捕获侧可立即交接每条已脱敏记录,也可按会话暂存一个前缀,等待显式释放。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)与[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)。 ## 后端契约 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`。 +`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它会在捕获或暂存前缀释放时,于 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `immediate` 或 `held` 投递模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `release(session)`。 ## 捕获点 -协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 +协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接或暂存;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。即时投递会交接生命周期记录;暂存投递会将上次释放后的任何后缀留在本地,包括随后的 shutdown 标记。 ## 脱敏 waterfall(瀑布式事件) -每条记录在投影与 `emit()` 之间都要经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。 +每条记录在投影后立即经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。暂存投递只保留 waterfall 的结果,因此后续移除策略也无法暴露捕获时的原始内容。脱敏只作用于外发副本;权威会话日志永不改写。 ## handoff 游标 -一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 +一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。即时投递在捕获时推进游标;暂存投递只有在 `release(session)` 将记录交给后端时才推进游标。因此,重建协调器后会通过确定性重新收养恢复未释放的前缀,而不会随其内存副本一同消失。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 ## 固定分片投影 @@ -40,3 +40,4 @@ - **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 - **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 +- **暂存前缀会重复占用内存**:暂存投递会为每个已投影事件保留一份深拷贝且已脱敏的记录,直到释放或回收会话。它不增加持久化 outbox,而是有意以内存换取简单的「触发前不上传」边界。 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 0bebbcc561..710e9b81f9 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -3,10 +3,11 @@ * firehose plus the one live-bus relay (`agent/error`), applies the fixed * chunk projection, builds logical records, runs each through the * `telemetry/record` waterfall (deployment-mounted redaction rules; - * pass-through when none), and hands the result to the backend — synchronously, with every - * handler self-contained so a failing backend can never starve other - * subscribers (cordis `emit` is stop-on-throw) or touch the agent loop. - * Composed by a backend in its constructor. + * pass-through when none), then hands the result to the backend immediately + * or holds it for explicit release. Every synchronous handler is + * self-contained so a failing backend can never starve other subscribers + * (cordis `emit` is stop-on-throw) or touch the agent loop. Composed by a + * backend in its constructor. * * @module @deepseek-ai/dsh-session-telemetry/coordinator */ @@ -16,6 +17,16 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' +/** Whether capture hands records over immediately or holds them for an explicit release. */ +export type TelemetryDelivery = 'immediate' | 'held' + +/** One redacted record waiting at the capture boundary. */ +interface PendingRecord { + readonly record: TelemetryRecord + /** Ledger cursor advanced only after the backend accepts this record. */ + readonly seq?: number +} + /** * The handoff cursor: per session, the highest `seq` handed to a backend. * Deliberately MODULE-scope ambient state — a narrow, documented exception @@ -35,14 +46,13 @@ const handoffCursor = new WeakMap<Session, number>() * Registers the persistence-coordinator listener set plus the `agent/error` * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and * sweeps already-live sessions (a hot reload does not replay - * `session/created`). A `session/disposed` emits the session's `shutdown` - * operational record — the marker rides the session's own termination edge, - * where receivers key crash detection — and retires it from the adopted set, - * so a long-lived backend neither retains closed sessions (and their frozen - * event logs) nor re-marks them at unload. Disposal marks the sessions still - * alive at teardown (their own edge would fire unobserved) and then awaits - * the backend's `shutdown()`; a failure there warns instead of throwing — - * best-effort reporting must not fail application teardown. + * `session/created`). A `session/disposed` captures the session's `shutdown` + * operational record at its own termination edge and retires it from the + * adopted set. Immediate delivery hands that marker over; held delivery keeps + * it local without another explicit release. Disposal captures the same + * marker for sessions still alive, then awaits the backend's `shutdown()`; a + * failure there warns instead of throwing — best-effort reporting must not + * fail application teardown. */ export class TelemetryCoordinator { /** @@ -53,28 +63,30 @@ export class TelemetryCoordinator { private readonly adopted = new Set<Session>() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap<Session, Set<string>>() + /** Redacted records retained until {@link release}; weak keys do not extend session lifetime. */ + private readonly held = new WeakMap<Session, PendingRecord[]>() /** * @param ctx - the composing backend's context; listeners bind to its fiber. * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding. + * @param delivery - immediate handoff, or held delivery released explicitly per session. */ constructor( private readonly ctx: Context, private readonly backend: TelemetryBackend, + private readonly delivery: TelemetryDelivery = 'immediate', ) { ctx.on('session/created', (session) => { this.adopt(session) }) - // The session's own termination edge: emit the shutdown marker HERE — - // receivers classify a session with activity and no marker as crashed, - // so a normally closed session in a long-running host must get its - // marker at disposal, not never. Then retire: the projection/cursor - // WeakMaps die with the Session object; only the strong adopted set - // needs the explicit release. + // Capture the shutdown marker at the session's own termination edge. + // Immediate delivery preserves crash classification; held delivery does + // not let a later lifecycle edge extend a user-released prefix. Then + // retire the only strong reference owned by this coordinator. ctx.on('session/disposed', (session) => { this.contain(() => { if (!this.adopted.delete(session)) return - this.handOff(shutdownRecord(session)) + this.submit(session, { record: this.redact(shutdownRecord(session)) }) }) }) ctx.on('session/event', (session, event) => { @@ -95,13 +107,12 @@ export class TelemetryCoordinator { }) }) ctx.effect(() => async () => { - // Sessions still adopted here are alive through a whole-application - // teardown (their own disposal edge will fire after telemetry is gone, - // unobserved) — mark them now so the receiver sees a clean stop of - // observation rather than a crash-shaped silence. + // Sessions still adopted here are alive through whole-application + // teardown, so capture the marker before the backend quiesces. Held + // delivery intentionally leaves it local without another release. for (const session of this.adopted) { this.contain(() => { - this.handOff(shutdownRecord(session)) + this.submit(session, { record: this.redact(shutdownRecord(session)) }) }) } try { @@ -115,6 +126,23 @@ export class TelemetryCoordinator { } } + /** + * Hand the records currently held for one session to the backend in capture order. + * Records captured after this call form a new held prefix. Backend failures remain + * contained per record and do not starve later records in the same release. + * @param session - session whose pending capture prefix may leave the process. + */ + release(session: Session): void { + const pending = this.held.get(session) + if (pending === undefined) return + this.held.delete(session) + for (const record of pending) { + this.contain(() => { + this.deliver(session, record) + }) + } + } + /** * Adopt a session: replay its log THROUGH the projection from the handoff * cursor, then rely on the firehose for everything after. When no cursor @@ -153,7 +181,7 @@ export class TelemetryCoordinator { } } - /** Project one event and hand it to the backend, advancing the cursor on handoff. */ + /** Project and redact one event, then submit it under the delivery policy. */ private capture(session: Session, event: SessionEvent): void { if (event.type === 'assistant/chunk') { const key = `${event.data.turn}:${event.data.step}` @@ -165,27 +193,47 @@ export class TelemetryCoordinator { if (seen.has(key)) return seen.add(key) } - this.handOff({ - channel: 'ledger', - time: event.time, - severity: severityOf(event), - attributes: identityOf(session, event), - // The live event object is mutable and the backend serializes later; - // append-time validation guarantees this clone cannot throw. - body: structuredClone(event.data), + this.submit(session, { + record: this.redact({ + channel: 'ledger', + time: event.time, + severity: severityOf(event), + attributes: identityOf(session, event), + // The live event object is mutable and the backend serializes later; + // append-time validation guarantees this clone cannot throw. + body: structuredClone(event.data), + }), + seq: event.seq, }) - handoffCursor.set(session, event.seq) } /** - * Run the `telemetry/record` waterfall over one record and hand the result - * to the backend. The innermost `next` passes the record through unchanged - * — the seam ships no rules; exported data is as clean as the listeners a - * deployment mounts. Callers run inside {@link contain}, so a throwing - * rule withholds the record instead of reaching the loop (fail-closed). + * Run the `telemetry/record` waterfall at capture time. The innermost `next` + * passes the record through unchanged — the seam ships no rules; exported + * data is as clean as the listeners a deployment mounts. Callers run inside + * {@link contain}, so a throwing rule withholds the record instead of + * reaching the loop (fail-closed). Held delivery stores only this result, so + * a later policy reload cannot expose the pre-redaction capture. */ - private handOff(record: TelemetryRecord): void { - this.backend.emit(this.ctx.waterfall('telemetry/record', record, () => record)) + private redact(record: TelemetryRecord): TelemetryRecord { + return this.ctx.waterfall('telemetry/record', record, () => record) + } + + /** Hold one redacted record or deliver it immediately under the configured policy. */ + private submit(session: Session, pending: PendingRecord): void { + if (this.delivery === 'held') { + let records = this.held.get(session) + if (records === undefined) this.held.set(session, records = []) + records.push(pending) + return + } + this.deliver(session, pending) + } + + /** Hand one redacted record to the backend, then advance its ledger cursor. */ + private deliver(session: Session, pending: PendingRecord): void { + this.backend.emit(pending.record) + if (pending.seq !== undefined) handoffCursor.set(session, pending.seq) } /** Forward the turn-end boundary to the backend's optional flush hint. */ @@ -196,19 +244,21 @@ export class TelemetryCoordinator { /** Relay one `agent/error` bus emission as an `agent-error` operational record. */ private relayAgentError(agent: Agent, turn: number, step: number, error: unknown): void { const detail = errorDetail(error) - this.handOff({ - channel: 'ops', - time: Date.now(), - severity: 'error', - attributes: { - 'telemetry.op': 'agent-error', - 'session.id': String(agent.session.id), - 'agent.id': agent.id, - 'error.name': detail.name, - turn, - step, - }, - body: detail, + this.submit(agent.session, { + record: this.redact({ + channel: 'ops', + time: Date.now(), + severity: 'error', + attributes: { + 'telemetry.op': 'agent-error', + 'session.id': String(agent.session.id), + 'agent.id': agent.id, + 'error.name': detail.name, + turn, + step, + }, + body: detail, + }), }) } diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index e7340eedd5..914ef96a95 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -3,8 +3,9 @@ * * The seam owns the CAPTURE side of session-event reporting — which records * exist (the chunk projection), what they carry (the logical record), when - * they are handed over (adoption, the per-append firehose, lifecycle - * forwarding), and the HMR handoff cursor. Everything downstream of + * they are captured (adoption, the per-append firehose, lifecycle + * forwarding), immediate versus explicitly released handoff, and the HMR + * cursor. Everything downstream of * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the * reporting SDK's territory and is deliberately not modelled here. The * design and its trade-offs are pinned in @@ -94,9 +95,10 @@ export interface TelemetryBackend { /** * Hand one record to the backend's pipeline. MUST be a non-blocking * enqueue — the coordinator calls this synchronously from the - * `session/event` hot path, so anything slower than a queue push would tax - * the agent loop. Errors thrown here are contained by the coordinator and - * logged; they never reach the loop. + * `session/event` hot path, either at capture or while releasing a held + * prefix, so anything slower than a queue push would tax the agent loop. + * Errors thrown here are contained by the coordinator and logged; they + * never reach the loop. * @param record - the logical record to report; owned by the backend after the call. */ emit(record: TelemetryRecord): void @@ -121,6 +123,9 @@ export interface TelemetryBackend { * coordinator emits its dispose-time `shutdown` markers immediately before * calling this). Awaited by the coordinator's dispose; a rejection is * logged as a warning and never fails application teardown. + * The coordinator captures dispose-time shutdown markers immediately + * before this call; immediate delivery enqueues them, while held delivery + * leaves an unreleased suffix local. * @returns resolves when the backend's pipeline has quiesced. */ shutdown(): Promise<void> @@ -153,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend { abstract shutdown(): Promise<void> } -export { TelemetryCoordinator } from './coordinator.ts' +export { TelemetryCoordinator, type TelemetryDelivery } from './coordinator.ts' diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index a449a4053d..d913e6a742 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -10,7 +10,12 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts' +import { + TelemetryCoordinator, + type TelemetryBackend, + type TelemetryDelivery, + type TelemetryRecord, +} from '../src/index.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -54,15 +59,21 @@ class FakeBackend implements TelemetryBackend { } } -async function setup(backend: FakeBackend = new FakeBackend()) { +async function setup( + backend: FakeBackend = new FakeBackend(), + delivery: TelemetryDelivery = 'immediate', +) { const ctx = new Context() await ctx.plugin(SessionStore) + let coordinator!: TelemetryCoordinator const fiber = await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], - apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + apply: (inner: Context) => { + coordinator = new TelemetryCoordinator(inner, backend, delivery) + }, }) - return { ctx, backend, fiber } + return { ctx, backend, coordinator, fiber } } function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session { @@ -167,6 +178,80 @@ describe('TelemetryCoordinator capture', () => { }) }) +describe('TelemetryCoordinator held delivery', () => { + it('releases one pending prefix at a time without handing later records over early', async () => { + const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held') + const session = liveSession(ctx, 'held-prefix') + appendTurn(session) + expect(backend.records).toEqual([]) + + coordinator.release(session) + expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([ + 'turn/start', + 'user/message', + ]) + + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(backend.ledger()).toHaveLength(2) + coordinator.release(session) + coordinator.release(session) + expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([ + 'turn/start', + 'user/message', + 'turn/end', + ]) + }) + + it('stores the capture-time redacted copy rather than re-running policy at release', async () => { + const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held') + const disposeRule = ctx.on('telemetry/record', (_record, next) => ({ + ...next(), + body: { scrubbed: true }, + })) + const session = liveSession(ctx, 'held-redacted') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + disposeRule() + + coordinator.release(session) + expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true }) + }) + + it('contains each backend failure independently while releasing a batch', async () => { + const backend = new FakeBackend() + backend.rejectSeq = 1 + const { ctx, coordinator } = await setup(backend, 'held') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = liveSession(ctx, 'held-failure') + appendTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + coordinator.release(session) + expect(backend.ledger().map(record => record.attributes['event.seq'])).toEqual([0, 2]) + expect(warn).toHaveBeenCalled() + }) + + it('rebuilds an unreleased prefix after coordinator reload', async () => { + const first = new FakeBackend() + const { ctx, fiber } = await setup(first, 'held') + const session = liveSession(ctx, 'held-reload') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + expect(first.records).toEqual([]) + + const second = new FakeBackend() + let coordinator!: TelemetryCoordinator + await ctx.plugin({ + name: 'fake-telemetry-after-held-reload', + inject: ['sessions'], + apply: (inner: Context) => { + coordinator = new TelemetryCoordinator(inner, second, 'held') + }, + }) + coordinator.release(session) + expect(second.ledger().map(record => record.attributes['event.seq'])).toEqual([0]) + }) +}) + describe('TelemetryCoordinator adoption', () => { it('exports an unpublished suffix without re-exporting constructor history', async () => { const backend = new FakeBackend() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ecfd0e869..882d42c8d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,6 +445,9 @@ importers: '@cordisjs/plugin-include': specifier: workspace:* version: link:../vendor/include + '@cordisjs/plugin-logger-console': + specifier: workspace:* + version: link:../vendor/logger-console '@deepseek-ai/dsh-acp-demo': specifier: workspace:* version: link:../packages/examples/acp-demo @@ -466,6 +469,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:* version: link:../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-feedback': + specifier: workspace:* + version: link:../packages/feedback/command-feedback '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic @@ -4810,6 +4816,9 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-command-feedback': + specifier: workspace:^ + version: link:../../feedback/command-feedback '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From 9d9b547d55dc6a2db4449193bcc505e2b5282712 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Wed, 5 Aug 2026 12:47:53 +0800 Subject: [PATCH 052/190] docs: refresh telemetry module graph --- docs/module-graph.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 61477ae104..bddaf47f98 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -630,10 +630,6 @@ flowchart TD pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout - pkg_session_telemetry_otel --> pkg_invariants - pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_session - pkg_session_telemetry_otel --> pkg_session_telemetry pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -813,6 +809,11 @@ flowchart TD pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools + pkg_session_telemetry_otel --> pkg_command_feedback + pkg_session_telemetry_otel --> pkg_invariants + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1096,7 +1097,6 @@ flowchart TD | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | -| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`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) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | @@ -1126,6 +1126,7 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`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) | +| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`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-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) | From 698fdaea9b641b0e69dbd6f8ca04fc04be7c114d Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 12:59:20 +0800 Subject: [PATCH 053/190] fix(tools): emit Python SDK members in one lexicographic stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python renderer partitioned identifier methods ahead of subscript comments, so a tool set like {a-tool, z} emitted z first — contradicting the documented lexicographic contract and the TypeScript flavor, which quotes exotic keys in place. Interleave both kinds in one ordered stream and track emitted statements for the pass fallback. Also correct four stale serialization claims in the base Code Mode note that the live-parallel scheduler superseded. --- .../feature/2026-06-15-code-mode.i18n.yaml | 4 +-- .../feature/2026-06-15-code-mode.md | 8 +++--- .../feature/2026-06-15-code-mode.zh.md | 8 +++--- packages/core/tools/src/py-types.ts | 26 +++++++++++-------- packages/core/tools/tests/py-types.spec.ts | 17 ++++++++++-- 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 75b8ed0e80..87e3ee0566 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-code-mode.md -2026-06-15-code-mode.md: 2bbd2357ce3ec19acac732c1f63a88d5b47dc3a8 -2026-06-15-code-mode.zh.md: 94ee9ae09763a7e8d6e27b3bed7b7a6443a55566 +2026-06-15-code-mode.md: 4aa735fbe18a160fa69b9130fa8cb843f7be5723 +2026-06-15-code-mode.zh.md: 642e8d5d24390fb14b050e2d255cc3f7112413c8 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md index 2bbd2357ce..4aa735fbe1 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md @@ -48,7 +48,7 @@ Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentat **Sub-call contexts are deferred through the parent.** Injecting inside `run_code` would break parent call/result adjacency, so `ToolRunContext.deferContext()` collects every sub-result `additionalContexts` entry in dispatch order. The registry carries that array even when the program later throws, and the loop appends each entry only after the outer result and every sibling result in the step. An outer post-execute block discards tool-deferred entries and exposes only contexts explicitly attached by the blocking decision. -**Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata. +**Concurrency is bounded, not serialized.** Each run owns a dispatch queue that starts calls strictly in submission order and classifies each one through `registry.executionMode`, the same fail-closed `isConcurrencySafe` contract the native loop uses. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (default 10; `1` restores serial dispatch); an exclusive call drains the pool and runs alone. Settlement abandons queued calls that have not started. This note shipped the serialized placeholder; the [live-parallel Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduler that replaced it. **Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md). @@ -85,11 +85,11 @@ The worker runtime provides containment, not a security boundary: model code can ### What the model sees -The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under the language's concurrency primitive (`Promise.all` in TypeScript, `asyncio.gather` in Python). The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. +The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Both flavors state the same contract in their own primitive: independent read-only calls MAY overlap under `Promise.all` (TypeScript) or `asyncio.gather` (Python), mutating calls run alone in submission order, and dependent work sequences with `await`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. ## Consequences -Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, while per-call contexts retain their source, envelope, and metadata through the outer result. +Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch starts in submission order under a bounded overlap pool, while per-call contexts retain their source, envelope, and metadata through the outer result. ## Testing @@ -128,6 +128,6 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **Large lossless JSON values can exhaust memory.** Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary. -**Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs. +**Sub-dispatch overlap is bounded by tool safety claims, not by the caller.** A program's `Promise.all` or `asyncio.gather` buys wall-clock parallelism only across calls the tool itself classifies concurrency-safe; a run of exclusive calls still costs its round-trips in sequence, and models may over-expect. Both flavors' SDK instructions state the real contract. This note shipped the serialized placeholder that made the risk absolute; the [live-parallel Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduler and its overlap cap. **Budget metering reads the event loop, not a flag.** Busy-time polling (`eventLoopUtilization()`) is coarser than an exact CPU meter — a budget expires up to one poll interval late — and its correctness claim ("a pending dispatch cannot pause it") is load-bearing against a hostile program. Both sides are unit-tested (hot loop with a pending decoy dispatch dies at `computeMs`; idle-on-slow-binding survives to `maxWallMs`), and the poll interval is an internal constant, not config — nothing a deployment could mis-tune into a bypass. `maxWallMs` is config, and it reaches `setTimeout`, which clamps a delay above `MAX_TIMER_DELAY_MS` (2^31-1 ms) to 1 ms; a positivity check alone therefore accepts a 25-day ceiling that expires on the first tick and times out every run. The worker runtime range-checks the field at load for that reason. `computeMs` needs no upper bound because it is compared against measured utilization instead of being handed to a timer. diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index 94ee9ae097..642e8d5d24 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -48,7 +48,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 **子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。 -**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。 +**并发是有界的,而非被序列化。** 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 `registry.executionMode` 对每个调用分类——与原生循环所用的 fail-closed `isConcurrencySafe` 契约相同。连续的 parallel 类调用最多重叠 `maxParallelSubCalls` 个(默认 10;设为 `1` 恢复串行分发);exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) 负责。 **呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput`;`run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时(Web)会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy 输出落盘预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。 @@ -85,11 +85,11 @@ worker 运行时只能约束程序的运行,而不构成安全边界:模型 ### 模型看到的内容 -SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-code-mode-language-dispatch.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在该语言的并发原语(TypeScript 为 `Promise.all`,Python 为 `asyncio.gather`)下,调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 +SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-code-mode-language-dispatch.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。两种 flavor 用各自的原语陈述同一契约:相互独立的只读调用可以在 `Promise.all`(TypeScript)或 `asyncio.gather`(Python)下重叠,有副作用的调用按提交顺序单独运行,有依赖的工作用 `await` 排序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 ## 后果 -切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。 +切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发在有界的重叠池下按提交顺序启动,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。 ## 测试 @@ -128,6 +128,6 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 返回值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。 -**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定。 +**子分发的重叠由工具自身的安全声明限定,而非由调用方决定。** 程序里的 `Promise.all` 或 `asyncio.gather` 只在工具自己分类为并发安全的调用之间换来挂钟并行性;一串 exclusive 调用仍要按顺序付出各自的往返开销,模型可能过度期望。两种 flavor 的 SDK 说明都陈述了真实契约。本 note 交付的是使该风险绝对化的序列化占位实现;调度器及其重叠上限由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) 负责。 **预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)是抵御恶意程序的关键。两种情况均有单元测试(带 pending 诱饵分发的热循环会在耗尽 `computeMs` 预算时终止;等待慢速绑定的空闲程序则会持续运行至 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。`maxWallMs` 是配置项,且会传入 `setTimeout`,后者会把超过 `MAX_TIMER_DELAY_MS`(2^31-1 ms)的延迟夹到 1 ms;因此仅有正数校验会放行一个 25 天的上限,它在第一个 tick 就到期,使每次运行都超时。worker 运行时正因如此在加载时对该字段做范围校验。`computeMs` 不需要上界,因为它对照的是实测占用率,而不是交给定时器。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index a03ebd61fc..25cb007fce 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -471,30 +471,34 @@ The available tools:` export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) } - const inlineMembers: string[] = [] - const subscriptMembers: string[] = [] + // ONE ordered member stream, matching the documented lexicographic contract + // and the TypeScript flavor (which quotes exotic keys in place rather than + // partitioning them out). Interleaving is free here: a comment line between + // two `async def` lines is not a statement, so it changes nothing about how + // the class body parses. + const members: string[] = [] + let statements = 0 for (const schema of sorted) { const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state) const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state) if (IDENTIFIER.test(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) { - inlineMembers.push(...docLines(schema.description, 1)) - inlineMembers.push(`${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`) + members.push(...docLines(schema.description, 1)) + members.push(`${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`) + statements += 1 } else { // Not a legal attribute name — the model reaches it via ``tools[name]``. // The stub lists it as a subscript comment (referencing the named // TypedDicts too) so a reader sees what is accessible; runtime resolution // goes through the proxy's __getitem__. - subscriptMembers.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`) + members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`) const description = describe(schema) - if (description !== undefined) subscriptMembers.push(`${pad(1)}# ${description}`) + if (description !== undefined) members.push(`${pad(1)}# ${description}`) } } // Subscript entries are COMMENTS, not statements: a class body of only - // comments fails to parse, so `pass` is required whenever no inline method - // exists — including the subscript-only tool set. - const bodyLines = inlineMembers.length > 0 - ? [...inlineMembers, ...subscriptMembers] - : [`${pad(1)}pass`, ...subscriptMembers] + // comments fails to parse, so `pass` is required whenever no method was + // emitted — including the subscript-only tool set. + const bodyLines = statements > 0 ? members : [`${pad(1)}pass`, ...members] const body = bodyLines.join('\n') const imports = TYPING_ORDER.filter(symbol => state.typing.has(symbol)) const classBlock = state.classes.length > 0 ? `${state.classes.join('\n\n')}\n\n` : '' diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 0cc748e408..4b42b1c630 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -390,11 +390,24 @@ describe('renderToolsSdkPy', () => { // Descriptions on subscript names ride as a comment beside their entry. expect(text).toContain('# tools["my-mcp.tool"]') expect(text).toContain('# Exotic name.') - // Lexicographic: `bash` before `my-mcp.tool` (identifier methods first, - // then subscript comments — the emitter partitions). + // Lexicographic: `bash` before `my-mcp.tool`. expect(text.indexOf('async def bash')).toBeLessThan(text.indexOf('# tools["my-mcp.tool"]')) }) + it('orders subscript entries against methods by name, not by member kind', () => { + // `a-tool` sorts before `z`, so the subscript comment must precede the + // method: one ordered stream, not methods-then-comments. + const noArgs = parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown> + const text = renderToolsSdkPy([ + { name: 'z', description: 'Last by name.', parameters: noArgs, output: { type: 'string' } }, + { name: 'a-tool', description: 'First by name.', parameters: noArgs, output: { type: 'string' } }, + ]) + expect(text.indexOf('# tools["a-tool"]')).toBeLessThan(text.indexOf('async def z')) + // The interleaved comment does not disturb the class body: `z` still parses + // as the statement that keeps `pass` out. + expect(text).not.toContain(`${' '.repeat(4)}pass`) + }) + it('is deterministic: byte-identical output regardless of input order or duplication', () => { expect(renderToolsSdkPy([bash, exotic])).toBe(renderToolsSdkPy([exotic, bash])) expect(renderToolsSdkPy([bash, bash])).toBe(renderToolsSdkPy([bash, bash])) From 1e202cd28eec13de5fa2607bfb5a0449f1c20b02 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 13:15:04 +0800 Subject: [PATCH 054/190] docs(notes): retire the parallel-native-dispatch TODO claims in the Code Mode note The rewritten scheduler paragraph states that the native loop already classifies through isConcurrencySafe, which contradicted two surviving present-tense claims that parallel native dispatch is an open TODO blocked on that same metadata. Both now attribute the TODO to decision time and point at the shipped rolling pool. --- .../notes/implemented/feature/2026-06-15-code-mode.i18n.yaml | 4 ++-- .agents/notes/implemented/feature/2026-06-15-code-mode.md | 4 ++-- .agents/notes/implemented/feature/2026-06-15-code-mode.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 87e3ee0566..bf428d8ae2 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-code-mode.md -2026-06-15-code-mode.md: 4aa735fbe18a160fa69b9130fa8cb843f7be5723 -2026-06-15-code-mode.zh.md: 642e8d5d24390fb14b050e2d255cc3f7112413c8 +2026-06-15-code-mode.md: d06e4f470e8155cf51b2127fe9b847f56ea2ff51 +2026-06-15-code-mode.zh.md: a29000c18553e44842d20ebbec191a3e2fd3b9cc diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md index 4aa735fbe1..d06e4f470e 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md @@ -6,7 +6,7 @@ English | [中文](2026-06-15-code-mode.zh.md) ## Problem -In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../../docs/architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request. +In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** — parallel tool execution was an open TODO at the time of this note, and bounded parallel dispatch has since shipped (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md); the rolling pool in [docs/architecture.md](../../../../docs/architecture.md)) — with **every** intermediate `tool-result` re-entering the model's context on the next request. For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not. @@ -106,7 +106,7 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls. -**Parallel native dispatch in the loop.** The other answer to round-trip cost; still valid future work (the open TODO), still blocked on concurrency-safety metadata, and still no composition — it parallelizes calls the model already decided on in one step. Code Mode's serialized-queue decision keeps the two compatible: when the metadata lands, both native parallel dispatch and per-tool binding parallelism unlock together. +**Parallel native dispatch in the loop.** The other answer to round-trip cost at decision time; it was blocked on concurrency-safety metadata and offers no composition either way — it parallelizes calls the model already decided on in one step. Code Mode's queue decision kept the two compatible, and that is how it played out: the metadata landed as `isConcurrencySafe` (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md)), and native rolling-pool dispatch and per-tool binding parallelism unlocked on the same classifier. **Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it. diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index 642e8d5d24..a29000c185 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../../docs/architecture.md) 中明确标注的 open TODO),且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。 +在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用——并行工具执行在本 note 写作时还是 open TODO,此后有界的并行分发已经交付(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.md),以及 [docs/architecture.md](../../../../docs/architecture.md) 中的 rolling pool)——且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。 对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。 @@ -106,7 +106,7 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **在原生工具调用上做结果省略/摘要。** 仅解决问题中上下文膨胀这一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。 -**循环中的并行原生分发。** 往返成本的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策保持两者兼容:当元数据就绪时,原生并行分发和每工具绑定并行化一起解锁。 +**循环中的并行原生分发。** 决策当时对往返成本的另一个答案;它被并发安全元数据阻塞,且无论如何都不提供组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的队列决策保持了两者兼容,后续也正是这样落地的:元数据以 `isConcurrencySafe` 的形式就绪(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.md)),原生 rolling-pool 分发与每工具绑定并行化基于同一个分类器一起解锁。 **始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是最优的,强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用,而不强加于人。 From 7a178951d6ae56a0eb622e2251525c1a82956f3b Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 14:02:47 +0800 Subject: [PATCH 055/190] fix(tools): attach Python SDK docstrings to their own methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A description was emitted above the `async def`, where Python treats the first string as the `Tools` class docstring and every later one as a dead expression — leaving each method undocumented in the model's only source of tool semantics. Emit it as the first statement of the method body instead. Also names the known languages in the run_code flavor guard (the reachable rejection, symmetric with the SDK_RENDERERS guard) and corrects three doc claims: the code-runtime group README no longer calls the generated SDK TypeScript, the base Code Mode note states its serial dispatch in past tense, and the tools README points at the rationale the language-dispatch note actually carries. --- .../feature/2026-06-15-code-mode.i18n.yaml | 4 +-- .../feature/2026-06-15-code-mode.md | 2 +- .../feature/2026-06-15-code-mode.zh.md | 2 +- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +-- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/code-runtime/README.i18n.yaml | 4 +-- packages/code-runtime/README.md | 2 +- packages/code-runtime/README.zh.md | 2 +- packages/core/tools/README.i18n.yaml | 4 +-- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/code-mode.ts | 3 +- packages/core/tools/src/py-types.ts | 14 +++++++-- packages/core/tools/tests/code-mode.spec.ts | 5 +++- packages/core/tools/tests/py-types.spec.ts | 29 +++++++++++++++++-- 16 files changed, 60 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index bf428d8ae2..bc05e497c5 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-code-mode.md -2026-06-15-code-mode.md: d06e4f470e8155cf51b2127fe9b847f56ea2ff51 -2026-06-15-code-mode.zh.md: a29000c18553e44842d20ebbec191a3e2fd3b9cc +2026-06-15-code-mode.md: 99bbed3edab32512f88ece9694d6519a1f89c2dd +2026-06-15-code-mode.zh.md: ca1bbe9ed3e412186763d1ed4fca9ed06669d4c3 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md index d06e4f470e..99bbed3eda 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md @@ -6,7 +6,7 @@ English | [中文](2026-06-15-code-mode.zh.md) ## Problem -In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** — parallel tool execution was an open TODO at the time of this note, and bounded parallel dispatch has since shipped (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md); the rolling pool in [docs/architecture.md](../../../../docs/architecture.md)) — with **every** intermediate `tool-result` re-entering the model's context on the next request. +In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and at the time of this note the loop dispatched each call through `ctx.tools.execute()` **sequentially** (parallel tool execution was an open TODO then; bounded parallel dispatch has since shipped — the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md), the rolling pool in [docs/architecture.md](../../../../docs/architecture.md)) — with **every** intermediate `tool-result` re-entering the model's context on the next request. For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not. diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index a29000c185..ca1bbe9ed3 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用——并行工具执行在本 note 写作时还是 open TODO,此后有界的并行分发已经交付(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.md),以及 [docs/architecture.md](../../../../docs/architecture.md) 中的 rolling pool)——且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。 +在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,而在本 note 写作时,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行当时还是 open TODO;此后有界的并行分发已经交付——见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.md),以及 [docs/architecture.md](../../../../docs/architecture.md) 中的 rolling pool)——且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。 对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index bffb432e93..3830e60848 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: e2d063eb5efc42f3079864479cf869ba4643bff1 -2026-07-31-code-mode-language-dispatch.zh.md: d911a43936cb0865533951de3dee845d135a22ca +2026-07-31-code-mode-language-dispatch.md: d1fb598e22926eb017f7d3e2a3d1cb14870d4f4d +2026-07-31-code-mode-language-dispatch.zh.md: b5fc8b660c32b3ebdd8eef79439d4dedeb75b0c9 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index e2d063eb5e..d1fb598e22 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -23,7 +23,7 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri ### The Python SDK renderer -`py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. +`py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. Lexicographic means one ordered member stream: a tool whose name is not a legal attribute is listed as a `tools[name]` comment in its sorted position rather than partitioned to the end, matching how the TypeScript flavor quotes an exotic key in place. Two Python-specific placements follow from that: a description becomes the method's docstring emitted as the FIRST statement of its body (above the `async def` the first one would document the `Tools` class and the rest would be dead expressions, leaving every method undocumented), and because comment lines are not statements, a tool set with no method at all still needs an explicit `pass`. `renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party registration (a `defineTool` literal or a raw registration) or a wire-derived plain JSON schema — the former is trusted per AGENTS.md, the latter is a `JSON.parse` product that physically cannot carry accessors, and `renderType` re-validates the whole tree on every call regardless — so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index d911a43936..b5fc8b660c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -23,7 +23,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ### Python SDK 渲染器 -`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。 +`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。由此带来两处 Python 特有的位置约定:描述会成为方法的 docstring,且必须作为方法体的**第一条语句**发出(放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档);而注释行不是语句,所以一个没有任何方法的工具集仍需显式 `pass`。 `renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的「校验后信任」姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对「访问器在多次读取间变值」的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是第一方注册(`defineTool` 字面量或 raw 注册)或从 wire 桥接而来的纯 JSON——前者按 AGENTS.md 受信任,后者是 `JSON.parse` 产物、物理上不可能携带访问器,且每次调用 `renderType` 都会整树重新校验——这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是「降级不支持的 schema」,而非「扛住对抗性的可变 schema」。 diff --git a/packages/code-runtime/README.i18n.yaml b/packages/code-runtime/README.i18n.yaml index d8eebec7aa..ebce8bc53a 100644 --- a/packages/code-runtime/README.i18n.yaml +++ b/packages/code-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/code-runtime/README.md -README.md: dbe6b37ffa01d07c6902672a06ebf6f88548ff99 -README.zh.md: a5acbad3cce19366ca9ca4729f5285905ab026eb +README.md: 4ee441bf99ddd59c2cf6e088cae6921ffebf7c75 +README.zh.md: 8a0d47fff43a9f894e8919d40a2934e20d47d62d diff --git a/packages/code-runtime/README.md b/packages/code-runtime/README.md index dbe6b37ffa..4ee441bf99 100644 --- a/packages/code-runtime/README.md +++ b/packages/code-runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages. +The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the SDK generated in the loaded runtime's `language`); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages. | Package | Role | ctx key | |---|---|---| diff --git a/packages/code-runtime/README.zh.md b/packages/code-runtime/README.zh.md index a5acbad3cc..8a0d47fff4 100644 --- a/packages/code-runtime/README.zh.md +++ b/packages/code-runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于针对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具与生成的 TypeScript SDK);设计记录在 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 中。这些都是**产品**包。 +代码执行能力 seam(参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于针对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)(`tools: { mode: code }`,即 `run_code` 工具与按所加载运行时 `language` 生成的 SDK);设计记录在 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 中。这些都是**产品**包。 | 包 | 职责 | ctx 键 | |---|---|---| diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index e27951bac0..fb90efa1db 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: f561a08bbc9645ea1bc127eedb04d2249a60a156 -README.zh.md: 7318b13a6640060176bb42af032f42456dd0d984 +README.md: 20df93e734afb9e7f4280d3aa208af2c8338001c +README.zh.md: d16a8a90c626c746b8629d148e432302f72b5f30 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index f561a08bbc..20df93e734 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -190,6 +190,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. -- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns why per-agent language switching is deferred). +- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns the lookup, and why the registry reads the loaded runtime instead of carrying a language field of its own). - **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 7318b13a66..d16a8a90c6 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -190,6 +190,6 @@ The available tools: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。 - **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。 -- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责说明为何暂缓逐 agent 切换语言)。 +- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责这次查表,以及注册表为何读取所加载的运行时而不自带 language 字段)。 - **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。 - **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 3c8e8ca024..7132ca3646 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -138,7 +138,8 @@ function resolveFlavor(peekRuntime: () => CodeRuntime | undefined): RunCodeFlavo // resolve an inherited Object.prototype member as a flavor. const flavor = RUN_CODE_FLAVORS[runtime.language] if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language) || flavor === undefined) { - throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)}`) + const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ') + throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) } return flavor } diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 25cb007fce..9f08d4dc3f 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -482,8 +482,18 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state) const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state) if (IDENTIFIER.test(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) { - members.push(...docLines(schema.description, 1)) - members.push(`${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`) + // A docstring only documents its method when it is the FIRST statement + // of that method's body. Emitted before the `async def` it would instead + // become the `Tools` class docstring (for the first tool) or a dead + // expression (for every later one), leaving every method undocumented — + // and this SDK is the model's only description of what a tool does. A + // docstring is a complete body, so the `...` stub is only for the + // description-less case. + const doc = docLines(schema.description, 2) + members.push(doc.length > 0 + ? `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}:` + : `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`) + members.push(...doc) statements += 1 } else { // Not a legal attribute name — the model reaches it via ``tools[name]``. diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index bbec3e5b26..933881fd50 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -381,7 +381,10 @@ describe('mode-aware wire contribution', () => { // rejects such a language earlier; this reaches the guard on its own. const { ctx } = await setup({ mode: 'code', runtime: { language: 'ruby' } }) const definition = ctx.tools.get(RUN_CODE_NAME) - expect(() => definition?.description).toThrow(/no run_code schema flavor registered for runtime language "ruby"/) + // Names the known languages, symmetric with the SDK_RENDERERS guard: this + // is the reachable rejection, so it must be at least as diagnosable. + expect(() => definition?.description) + .toThrow(/no run_code schema flavor registered for runtime language "ruby" \(known: "typescript", "python"\)/) }) it('degrades the run_code flavor to TypeScript when no runtime is mounted (doc-catalog schema harvest)', async () => { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 4b42b1c630..c829801efd 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -100,7 +100,7 @@ describe('renderToolsSdkPy', () => { expect(text).toContain('class Tools(Protocol):') // The argument object is a named TypedDict, not an opaque dict. expect(text).toContain('class BashArgs(TypedDict):') - expect(text).toContain('async def bash(self, args: BashArgs) -> str: ...') + expect(text).toContain('async def bash(self, args: BashArgs) -> str:') // Empty-property tools keep the opaque dict (nothing to name). expect(text).toContain('# tools["my-mcp.tool"](args: dict[str, Any]) -> str') expect(text).toContain('# tools["class"](args: dict[str, Any]) -> str') @@ -130,7 +130,7 @@ describe('renderToolsSdkPy', () => { expect(text).toContain(' query: str') expect(text).toContain(' # Max results.') expect(text).toContain(' limit: NotRequired[float]') - expect(text).toContain('async def search(self, args: SearchArgs) -> str: ...') + expect(text).toContain('async def search(self, args: SearchArgs) -> str:') // NotRequired is imported because an optional field used it; Any is NOT, // since every type here is concrete — the import line lists only what ran. expect(text).toContain('from typing import NotRequired, Protocol, TypedDict') @@ -327,7 +327,7 @@ describe('renderToolsSdkPy', () => { output: { type: 'string' }, } const text = renderToolsSdkPy([tool]) - expect(text).toContain('async def weird_fields(self, args: dict[str, Any]) -> str: ...') + expect(text).toContain('async def weird_fields(self, args: dict[str, Any]) -> str:') expect(text).not.toContain('WeirdFieldsArgs') }) @@ -394,6 +394,29 @@ describe('renderToolsSdkPy', () => { expect(text.indexOf('async def bash')).toBeLessThan(text.indexOf('# tools["my-mcp.tool"]')) }) + it('places a docstring as the first statement of its own method body', () => { + // Python attaches a docstring to a function only when it is that + // function's first statement. Above the `async def` the first one would + // document the `Tools` class and every later one would be a dead + // expression, so each method must open its body with its own docstring. + const second: ToolSdkSchema = { + name: 'zzz', + description: 'Second by name.', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>, + output: { type: 'string' }, + } + const lines = renderToolsSdkPy([bash, second]).split('\n') + for (const [name, doc] of [['bash', 'Run a shell command.'], ['zzz', 'Second by name.']]) { + const signature = lines.findIndex(line => line.startsWith(`${' '.repeat(4)}async def ${name}(`)) + expect(signature).toBeGreaterThan(-1) + // Ends in `:`, not the `: ...` stub — a docstring IS the whole body. + expect(lines[signature].endsWith(':')).toBe(true) + expect(lines[signature + 1]).toBe(`${' '.repeat(8)}"""${doc}"""`) + } + // No docstring is left floating at class-body indentation. + expect(lines.filter(line => line.startsWith(`${' '.repeat(4)}"""`))).toEqual([]) + }) + it('orders subscript entries against methods by name, not by member kind', () => { // `a-tool` sorts before `z`, so the subscript comment must precede the // method: one ordered stream, not methods-then-comments. From 3f7707e9aa888714c19e714a6c6c7329a7c6c404 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 14:03:43 +0800 Subject: [PATCH 056/190] test(tools): satisfy noUncheckedIndexedAccess in the docstring test --- packages/core/tools/tests/py-types.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index c829801efd..deb2bb6cd1 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -410,7 +410,7 @@ describe('renderToolsSdkPy', () => { const signature = lines.findIndex(line => line.startsWith(`${' '.repeat(4)}async def ${name}(`)) expect(signature).toBeGreaterThan(-1) // Ends in `:`, not the `: ...` stub — a docstring IS the whole body. - expect(lines[signature].endsWith(':')).toBe(true) + expect(lines[signature]?.endsWith(':')).toBe(true) expect(lines[signature + 1]).toBe(`${' '.repeat(8)}"""${doc}"""`) } // No docstring is left floating at class-body indentation. From a1d7b9a3cd864d56e7d015bedc8fbf73709736f9 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 14:05:02 +0800 Subject: [PATCH 057/190] fix(tools): treat a whitespace-only description as absent in the Python SDK It collapsed to '' rather than undefined, so the renderer emitted an empty `""""""` docstring or a bare `# ` line for a node that documents nothing. --- packages/core/tools/src/py-types.ts | 10 +++++++--- packages/core/tools/tests/py-types.spec.ts | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 9f08d4dc3f..26deed174f 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -82,7 +82,10 @@ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f]/g * The collapsed one-line `description` of a schema node (byte-stable across * formatting churn), or `undefined` when the node carries none. Every caller * passes an object (validated property nodes, or the ToolSdkSchema itself), - * so only the description field needs guarding. + * so only the description field needs guarding. A description that collapses + * to nothing (empty, or whitespace only) is `undefined` too: it documents the + * node no better than an absent one, and emitting it would leave an empty + * `"""` docstring or a bare `# ` line in the SDK. * * Control characters left over after the whitespace collapse are rendered as * their `\xNN` escapes (see {@link UNPRINTABLE}); the escape's own backslash is @@ -91,11 +94,12 @@ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f]/g */ function describe(schema: object): string | undefined { const description = (schema as Record<string, unknown>).description - if (typeof description !== 'string' || description.length === 0) return undefined - return description + if (typeof description !== 'string') return undefined + const collapsed = description .replace(/\s+/g, ' ') .replace(UNPRINTABLE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`) .trim() + return collapsed.length === 0 ? undefined : collapsed } /** diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index deb2bb6cd1..5a4b7f625a 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -464,6 +464,13 @@ describe('renderToolsSdkPy', () => { // Subscript entry appears without the "# ..." description follow-up. expect(text).toContain('# tools["weird-name"]') expect(text.split('\n').every(line => !line.startsWith(' # '))).toBe(true) + // A whitespace-only description collapses to nothing and is treated as + // absent: no empty `""""""` docstring, no bare `# ` line. + const blank = renderToolsSdkPy([ + { ...undescribedIdentifier, description: ' \t\n ' }, + { ...undescribedExotic, description: ' ' }, + ]) + expect(blank).toBe(text) }) it('marks an open object TypedDict and declares a closed empty object', () => { From 95da76069686d51f34e1d07cef003256687cb413 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 14:59:52 +0800 Subject: [PATCH 058/190] fix(tools): cap Python SDK list nesting at CPython's bracket limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A schema nesting arrays past ~200 levels rendered a `list[list[...]]` chain CPython's tokenizer rejects outright (`too many nested parentheses`), so the SDK block was not valid Python at all — the failure docstring escaping in the same file already guards against. The chain now degrades to `Any` at 180 levels; nesting restarts per TypedDict field, since a field annotation is its own logical line. Unions and nested objects are unaffected: neither accumulates open brackets. Also aligns the unreachable SDK_RENDERERS guard message with the two reachable ones, and corrects a test comment that still said class docstring. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/index.ts | 2 +- packages/core/tools/src/py-types.ts | 50 ++++++++++++++++--- packages/core/tools/tests/py-types.spec.ts | 31 ++++++++++-- 6 files changed, 73 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 3830e60848..3354a86d56 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: d1fb598e22926eb017f7d3e2a3d1cb14870d4f4d -2026-07-31-code-mode-language-dispatch.zh.md: b5fc8b660c32b3ebdd8eef79439d4dedeb75b0c9 +2026-07-31-code-mode-language-dispatch.md: 6245891651aece73d5a51a6341bc4f76b98fad12 +2026-07-31-code-mode-language-dispatch.zh.md: 23dbd1c2a9d049d0648109c474b09feaae28886e diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index d1fb598e22..6245891651 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -23,7 +23,7 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri ### The Python SDK renderer -`py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. Lexicographic means one ordered member stream: a tool whose name is not a legal attribute is listed as a `tools[name]` comment in its sorted position rather than partitioned to the end, matching how the TypeScript flavor quotes an exotic key in place. Two Python-specific placements follow from that: a description becomes the method's docstring emitted as the FIRST statement of its body (above the `async def` the first one would document the `Tools` class and the rest would be dead expressions, leaving every method undocumented), and because comment lines are not statements, a tool set with no method at all still needs an explicit `pass`. +`py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. Lexicographic means one ordered member stream: a tool whose name is not a legal attribute is listed as a `tools[name]` comment in its sorted position rather than partitioned to the end, matching how the TypeScript flavor quotes an exotic key in place. That stream forces one thing directly — comment lines are not statements, so a tool set that emits no method at all still needs an explicit `pass`. Two further rules are Python-specific rather than consequences of the ordering. A description becomes the method's docstring emitted as the FIRST statement of its body: above the `async def` the first one would document the `Tools` class and the rest would be dead expressions, leaving every method undocumented. And a `list[…]` chain degrades to `Any` past `MAX_LIST_NESTING`, because CPython's tokenizer rejects a line with more than 200 open brackets and the block must stay parseable Python — the same reason `docLines` escapes quotes and backslashes. `ts-types` needs neither: TypeScript attaches a leading `/** … */` to the member that follows it and bounds nesting nowhere in its grammar. `renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party registration (a `defineTool` literal or a raw registration) or a wire-derived plain JSON schema — the former is trusted per AGENTS.md, the latter is a `JSON.parse` product that physically cannot carry accessors, and `renderType` re-validates the whole tree on every call regardless — so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index b5fc8b660c..23dbd1c2a9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -23,7 +23,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ### Python SDK 渲染器 -`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。由此带来两处 Python 特有的位置约定:描述会成为方法的 docstring,且必须作为方法体的**第一条语句**发出(放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档);而注释行不是语句,所以一个没有任何方法的工具集仍需显式 `pass`。 +`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。这个成员流直接决定了一件事:注释行不是语句,所以一个不发出任何方法的工具集仍需显式 `pass`。另有两条规则并非源自排序,而是 Python 特有。其一,描述会成为方法的 docstring,且必须作为方法体的**第一条语句**发出:放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档。其二,`list[…]` 链超过 `MAX_LIST_NESTING` 后降级为 `Any`,因为 CPython 的 tokenizer 拒绝一行中超过 200 个同时未闭合的括号,而这个块必须是可解析的 Python——与 `docLines` 转义引号和反斜杠是同一个理由。`ts-types` 两者都不需要:TypeScript 会把前置的 `/** … */` 附着到其后的成员上,其语法也不对嵌套设限。 `renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的「校验后信任」姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对「访问器在多次读取间变值」的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是第一方注册(`defineTool` 字面量或 raw 注册)或从 wire 桥接而来的纯 JSON——前者按 AGENTS.md 受信任,后者是 `JSON.parse` 产物、物理上不可能携带访问器,且每次调用 `renderType` 都会整树重新校验——这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是「降级不支持的 schema」,而非「扛住对抗性的可变 schema」。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 7e3d1f5624..5c523af878 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -795,7 +795,7 @@ export class ToolRegistry extends Service { const render = SDK_RENDERERS[runtime.language] /* v8 ignore next 3 -- requireCodeRuntime rejects an unknown language before this ever runs. */ if (!Object.hasOwn(SDK_RENDERERS, runtime.language) || render === undefined) { - throw new Error(`dsh-tools: no SDK renderer registered for runtime language "${runtime.language}"`) + throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')})`) } return render(this.sdkSchemas(context.scope)) }, diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 26deed174f..0472f06029 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -129,6 +129,24 @@ function camelCase(raw: string): string { /** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */ const MAX_CLASS_NAME_BASE = 120 +/** + * Deepest `list[…]` nesting emitted into one annotation before the item type + * degrades to `Any`. CPython's tokenizer rejects a logical line holding more + * than 200 simultaneously-open brackets (`MAXLEVEL`, `SyntaxError: too many + * nested parentheses`), so an array chain deeper than that would render an SDK + * block that is not valid Python at all — the same failure the docstring + * escaping in {@link docLines} exists to prevent. 180 leaves headroom for the + * one bracket an annotation can add around the chain (`NotRequired[…]`). + * + * A CPython grammar limit, not a deployment choice, so it is fixed rather than + * configurable. The sibling `ts-types` renderer needs no counterpart: nothing + * in the TypeScript grammar bounds nesting, and its SDK block is never type- + * checked. Only bracket nesting counts — a `oneOf` renders as a flat `A | B` + * chain and nested objects render as separate `class` statements, so neither + * accumulates open brackets at any depth. + */ +const MAX_LIST_NESTING = 180 + /** Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for why capping keeps the render linear). */ function capClassNameBase(base: string): string { return base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base @@ -238,14 +256,16 @@ function renderType(schema: unknown, className: string, state: RenderState): str phase: 'start' | 'children' kind?: 'oneOf' | 'array' | 'typeddict' node?: JsonSchemaNode - children: { schema: JsonSchemaNode; className: string }[] + /** Open `list[` brackets enclosing this node in the annotation being built ({@link MAX_LIST_NESTING}). */ + listDepth: number + children: { schema: JsonSchemaNode; className: string; listDepth: number }[] childIndex: number childTypes: string[] entries: [string, JsonSchemaNode][] allocated?: string } - const newFrame = (schema: JsonSchemaNode, className: string): Frame => - ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [] }) + const newFrame = (schema: JsonSchemaNode, className: string, listDepth: number): Frame => + ({ schema, className, phase: 'start', listDepth, children: [], childIndex: 0, childTypes: [], entries: [] }) try { // Validate the WHOLE tree once, then trust it — the same contract the // sibling ts-types renderer follows at a typed same-process seam. Every @@ -254,7 +274,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str // here (before anything is emitted) and degrades to `Any`, the Python // counterpart of the TS flavor's `unknown`. assertSupportedJsonSchema(schema) - const frames: Frame[] = [newFrame(schema, className)] + const frames: Frame[] = [newFrame(schema, className, 0)] let result: string | undefined /* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */ @@ -276,7 +296,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str /* v8 ignore next -- childIndex is bounded by children.length. */ if (child === undefined) throw new Error('missing python render child') frame.childIndex++ - frames.push(newFrame(child.schema, child.className)) + frames.push(newFrame(child.schema, child.className, child.listDepth)) continue } if (frame.kind === 'oneOf') { @@ -345,7 +365,9 @@ function renderType(schema: unknown, className: string, state: RenderState): str const node = frame.schema if (node.oneOf !== undefined) { frame.kind = 'oneOf' - frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`) })) + // A union renders as `A | B` — no brackets of its own, so the branches + // inherit the enclosing depth unchanged. + frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`), listDepth: frame.listDepth })) continue } if (node.type === undefined) { @@ -365,9 +387,18 @@ function renderType(schema: unknown, className: string, state: RenderState): str finish('list[Any]') break } + // Past MAX_LIST_NESTING another `list[` would push the annotation + // beyond CPython's open-bracket limit and make the whole SDK block + // unparseable, so the chain degrades here instead — an unusable + // annotation either way, and this one is valid Python. + if (frame.listDepth >= MAX_LIST_NESTING) { + state.typing.add('Any') + finish('Any') + break + } // An array of objects names its item type after the array field. frame.kind = 'array' - frame.children = [{ schema: node.items, className: frame.className }] + frame.children = [{ schema: node.items, className: frame.className, listDepth: frame.listDepth + 1 }] break } case 'object': { @@ -404,7 +435,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str frame.entries = entries // frame.allocated was assigned two statements up; the ?? arm is for the type system only. /* v8 ignore next -- allocated is always set before children are built. */ - frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)) })) + // A field annotation is its own logical line, so nesting restarts — + // at 1, reserving the bracket an optional field's `NotRequired[…]` + // wraps around it. + frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)), listDepth: 1 })) break } /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */ diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 5a4b7f625a..734debc088 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -500,16 +500,37 @@ describe('renderToolsSdkPy', () => { expect(text).toContain('closedEmpty: OpennessArgsClosedEmpty') }) - it('renders a deeply nested array schema without exhausting the call stack', () => { + it('renders a deeply nested array schema without exhausting the call stack, capped at CPython\'s bracket limit', () => { // The registry supports depth-unbounded schemas; the renderer must not - // reintroduce a recursion limit during prompt assembly. + // reintroduce a recursion limit during prompt assembly. It must also not + // emit more open brackets than CPython's tokenizer accepts (200), so the + // chain degrades to `Any` at MAX_LIST_NESTING instead of rendering an SDK + // block that is not valid Python. let deep: Record<string, unknown> = { type: 'string' } for (let i = 0; i < 20000; i++) deep = { type: 'array', items: deep } const type = jsonSchemaToPy(deep) expect(type.startsWith('list[list[')).toBe(true) expect(type.endsWith(']]')).toBe(true) - expect(type).toContain('str') - expect(type.length).toBe('list['.length * 20000 + 'str'.length + ']'.repeat(20000).length) + // 180 `list[` levels around `Any`, not 20000 around `str`. + expect(type).toBe(`${'list['.repeat(180)}Any${']'.repeat(180)}`) + expect(type.split('[').length - 1).toBeLessThan(200) + }) + + it('keeps a chain just under the nesting cap exact, and restarts nesting per TypedDict field', () => { + // 179 levels still render the real item type: the cap degrades only what + // would not parse. + let under: Record<string, unknown> = { type: 'string' } + for (let i = 0; i < 179; i++) under = { type: 'array', items: under } + expect(jsonSchemaToPy(under)).toBe(`${'list['.repeat(179)}str${']'.repeat(179)}`) + // A field annotation is a fresh logical line, so a 179-deep chain reached + // THROUGH an object field is unaffected by the depth spent on the object. + const tool: ToolSdkSchema = { + name: 'deep_field', + description: 'Deep array under a field.', + parameters: { type: 'object', additionalProperties: false, properties: { rows: under }, required: ['rows'] }, + output: { type: 'string' }, + } + expect(renderToolsSdkPy([tool])).toContain(` rows: ${'list['.repeat(179)}str${']'.repeat(179)}`) }) it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => { @@ -664,7 +685,7 @@ describe('renderToolsSdkPy', () => { output: { type: 'string' }, }) const nul = renderToolsSdkPy([make('before\u0000after')]) - // Both emission sites: the class docstring and the `#` field comment. The + // Both emission sites: the method docstring and the `#` field comment. The // docstring's backslash is doubled by the same escaping that keeps a literal // backslash from escaping the closing triple quote, so Python parses it back // to the visible `\x00` the comment shows directly. Neither carries the byte. From 0d17baae01981d57c79122acfb99d2e7343b211c Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 15:55:42 +0800 Subject: [PATCH 059/190] fix(tools): restore the v8 ignore adjacency broken by an inserted comment The directive must sit on the line before its target; the nesting-cap comment displaced it onto a comment line, leaving the `?? ''` arm uncovered. --- packages/core/tools/src/py-types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 0472f06029..4327716917 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -433,11 +433,11 @@ function renderType(schema: unknown, className: string, state: RenderState): str frame.allocated = allocateClassName(frame.className, state) state.typing.add('TypedDict') frame.entries = entries - // frame.allocated was assigned two statements up; the ?? arm is for the type system only. - /* v8 ignore next -- allocated is always set before children are built. */ // A field annotation is its own logical line, so nesting restarts — // at 1, reserving the bracket an optional field's `NotRequired[…]` - // wraps around it. + // wraps around it. frame.allocated was assigned three statements up; + // the ?? arm is for the type system only. + /* v8 ignore next -- allocated is always set before children are built. */ frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)), listDepth: 1 })) break } From cc6e4d59fc43e9e4bd4e52e9b8c79415ba3d0d2d Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 15:59:15 +0800 Subject: [PATCH 060/190] docs(tools): scope the Python SDK validity standard to the grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list-nesting cap guards against a tokenizer SyntaxError, which makes the text not Python. A long `A | B | …` union is valid at any length and only defeats CPython's compile-time C recursion (measured: 1,000 branches compile, 5,000 raise RecursionError); nothing compiles this block, and capping would retire the deep-chain tests pinning the walk's linear time. Records that boundary at the `oneOf` arm and in the Agent Note (both languages). Also documents that the context-free degrade marker reads the call's className rather than the frame's — frames propagate a derived name, so a per-frame read would declare classes the caller cannot receive — and pins that path with oneOf-of-objects and array-of-oneOf assertions. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +-- .../2026-07-31-code-mode-language-dispatch.md | 2 ++ ...26-07-31-code-mode-language-dispatch.zh.md | 2 ++ packages/core/tools/src/py-types.ts | 26 ++++++++++++++++--- packages/core/tools/tests/py-types.spec.ts | 9 +++++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 3354a86d56..17fbb7d6a9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 6245891651aece73d5a51a6341bc4f76b98fad12 -2026-07-31-code-mode-language-dispatch.zh.md: 23dbd1c2a9d049d0648109c474b09feaae28886e +2026-07-31-code-mode-language-dispatch.md: 5785565296cd06e8e1b4761969449e51d1e3af0d +2026-07-31-code-mode-language-dispatch.zh.md: 6e9d39bb117b2b18c0291bc047c4972e140a0b6e diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 6245891651..5785565296 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -25,6 +25,8 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri `py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. Lexicographic means one ordered member stream: a tool whose name is not a legal attribute is listed as a `tools[name]` comment in its sorted position rather than partitioned to the end, matching how the TypeScript flavor quotes an exotic key in place. That stream forces one thing directly — comment lines are not statements, so a tool set that emits no method at all still needs an explicit `pass`. Two further rules are Python-specific rather than consequences of the ordering. A description becomes the method's docstring emitted as the FIRST statement of its body: above the `async def` the first one would document the `Tools` class and the rest would be dead expressions, leaving every method undocumented. And a `list[…]` chain degrades to `Any` past `MAX_LIST_NESTING`, because CPython's tokenizer rejects a line with more than 200 open brackets and the block must stay parseable Python — the same reason `docLines` escapes quotes and backslashes. `ts-types` needs neither: TypeScript attaches a leading `/** … */` to the member that follows it and bounds nesting nowhere in its grammar. +The standard that cap serves is grammatical validity, and the boundary is deliberate: a long `A | B | …` union is valid Python at any length and is left uncapped, even though CPython's `compile()` exhausts its C recursion walking the left-nested `BinOp` spine (measured on 3.9: 1,000 branches compile, 5,000 raise `RecursionError`). Nothing compiles this block — it is prompt text — so that limit costs nothing, whereas capping union length would retire the deep-chain tests that pin the walk's linear time and the class-name propagation cap. A future renderer that does need compilable output should flatten unions rather than truncate them. + `renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party registration (a `defineTool` literal or a raw registration) or a wire-derived plain JSON schema — the former is trusted per AGENTS.md, the latter is a `JSON.parse` product that physically cannot carry accessors, and `renderType` re-validates the whole tree on every call regardless — so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 23dbd1c2a9..6e9d39bb11 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -25,6 +25,8 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd `py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。这个成员流直接决定了一件事:注释行不是语句,所以一个不发出任何方法的工具集仍需显式 `pass`。另有两条规则并非源自排序,而是 Python 特有。其一,描述会成为方法的 docstring,且必须作为方法体的**第一条语句**发出:放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档。其二,`list[…]` 链超过 `MAX_LIST_NESTING` 后降级为 `Any`,因为 CPython 的 tokenizer 拒绝一行中超过 200 个同时未闭合的括号,而这个块必须是可解析的 Python——与 `docLines` 转义引号和反斜杠是同一个理由。`ts-types` 两者都不需要:TypeScript 会把前置的 `/** … */` 附着到其后的成员上,其语法也不对嵌套设限。 +该上限服务的标准是**语法合法性**,这条边界是有意划定的:长的 `A | B | …` union 在任何长度下都是合法 Python,故不设上限——尽管 CPython 的 `compile()` 在沿左嵌套 `BinOp` 脊柱下降时会耗尽 C 递归(在 3.9 上实测:1,000 个分支可编译,5,000 个抛 `RecursionError`)。没有任何东西会编译这个块——它是提示词文本——所以那条限制在这里没有代价;而给 union 长度封顶会作废那几个钉住 walk 线性时间与类名传播上限的深链测试。将来若有渲染器确实需要可编译的输出,应当把 union 拍平,而不是截断。 + `renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的「校验后信任」姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对「访问器在多次读取间变值」的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是第一方注册(`defineTool` 字面量或 raw 注册)或从 wire 桥接而来的纯 JSON——前者按 AGENTS.md 受信任,后者是 `JSON.parse` 产物、物理上不可能携带访问器,且每次调用 `renderType` 都会整树重新校验——这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是「降级不支持的 schema」,而非「扛住对抗性的可变 schema」。 ## Alternatives considered diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 4327716917..8b4b4f4991 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -136,14 +136,18 @@ const MAX_CLASS_NAME_BASE = 120 * nested parentheses`), so an array chain deeper than that would render an SDK * block that is not valid Python at all — the same failure the docstring * escaping in {@link docLines} exists to prevent. 180 leaves headroom for the - * one bracket an annotation can add around the chain (`NotRequired[…]`). + * few brackets an annotation can add around the chain: `NotRequired[…]`, a + * `Literal[…]` item, and the `def` parameter list an argument annotation sits + * inside, for a worst case of 182. * * A CPython grammar limit, not a deployment choice, so it is fixed rather than * configurable. The sibling `ts-types` renderer needs no counterpart: nothing * in the TypeScript grammar bounds nesting, and its SDK block is never type- * checked. Only bracket nesting counts — a `oneOf` renders as a flat `A | B` * chain and nested objects render as separate `class` statements, so neither - * accumulates open brackets at any depth. + * accumulates open brackets at any depth. The invariant this cap serves is + * grammatical validity; see the `oneOf` arm in {@link renderType} for the one + * interpreter limit deliberately left uncapped. */ const MAX_LIST_NESTING = 180 @@ -367,6 +371,18 @@ function renderType(schema: unknown, className: string, state: RenderState): str frame.kind = 'oneOf' // A union renders as `A | B` — no brackets of its own, so the branches // inherit the enclosing depth unchanged. + // + // Union LENGTH is deliberately uncapped, unlike list nesting. The two + // limits are different in kind: >200 open brackets is a SyntaxError + // from the tokenizer, so the text is not Python; a long `A | B | …` + // chain is grammatically valid at any length and only defeats CPython's + // C-recursion when `compile()` walks the left-nested BinOp spine + // (measured: 1,000 branches compile, 5,000 raise RecursionError). This + // block is prompt text — nothing compiles it — so that limit costs + // nothing here, while capping would retire the deep-chain tests that + // pin the walk's linear time and the class-name propagation cap. The + // standard this renderer holds is grammatical validity, not + // compilability under one interpreter's stack. frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`), listDepth: frame.listDepth })) continue } @@ -409,7 +425,11 @@ function renderType(schema: unknown, className: string, state: RenderState): str // than a permissive `dict[str, Any]`. const entries = Object.entries(node.properties ?? {}) // An empty `className` marks the context-free `jsonSchemaToPy` entry: - // there is no naming context to declare into, so degrade. A field + // there is no naming context to declare into, so degrade. This reads + // the CALL's className, not `frame.className`: the marker belongs to + // the whole walk, and frames propagate a derived name (a `oneOf` + // branch of the context-free root gets `Tool1`), so a per-frame read + // would declare classes the caller has no way to receive. A field // name that is not a legal Python attribute is inexpressible as a // class-syntax `TypedDict` field, so such an object degrades whole. // A leading-double-underscore non-dunder field (`__token`) would be diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 734debc088..a4eba3da1e 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -249,6 +249,12 @@ describe('renderToolsSdkPy', () => { ], }) expect(type).toBe('dict[str, Any] | str') + // Both branches objects, and the same shape reached through an array: the + // marker is the CALL's className, so a propagated frame name (`Tool1`) does + // not revive class declaration on a walk that has nowhere to declare into. + const object = { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] } + expect(jsonSchemaToPy({ oneOf: [object, object] })).toBe('dict[str, Any] | dict[str, Any]') + expect(jsonSchemaToPy({ type: 'array', items: { oneOf: [object, { type: 'string' }] } })).toBe('list[dict[str, Any] | str]') }) it('suffixes a counter when two tools CamelCase to the same class base', () => { @@ -539,6 +545,9 @@ describe('renderToolsSdkPy', () => { // depth the quadratic path (~100,000^2 char copies) blows past vitest's 5s // default, so this fails loud on a regression; the `+`/ConsString path is // milliseconds. (Guard the depth explicitly so the assertions stay exact.) + // The resulting chain is intentionally uncapped, unlike list nesting: it is + // grammatically valid Python at any length, and only CPython's `compile()` + // recursion would reject it — see the `oneOf` arm in py-types.ts. const depth = 100000 let deep: Record<string, unknown> = { type: 'string' } for (let i = 0; i < depth; i++) deep = { oneOf: [deep, { type: 'null' }] } From 581d2ee62161802f2afd2c8a10e59750fd7921f4 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 16:31:07 +0800 Subject: [PATCH 061/190] docs(tools): correct the propagated branch-name example to the index-derived 1 --- packages/core/tools/src/py-types.ts | 7 +++++-- packages/core/tools/tests/py-types.spec.ts | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 8b4b4f4991..487ea9ae8d 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -428,8 +428,11 @@ function renderType(schema: unknown, className: string, state: RenderState): str // there is no naming context to declare into, so degrade. This reads // the CALL's className, not `frame.className`: the marker belongs to // the whole walk, and frames propagate a derived name (a `oneOf` - // branch of the context-free root gets `Tool1`), so a per-frame read - // would declare classes the caller has no way to receive. A field + // branch of the context-free root gets the index-derived name `1` — + // `childClassName` concatenates and caps, it does not go through + // `camelCase`), so a per-frame read would declare classes the caller + // has no way to receive, under a name that is not even a legal + // identifier: `class 1(TypedDict):`. A field // name that is not a legal Python attribute is inexpressible as a // class-syntax `TypedDict` field, so such an object degrades whole. // A leading-double-underscore non-dunder field (`__token`) would be diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index a4eba3da1e..6dfbad9d0f 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -250,8 +250,9 @@ describe('renderToolsSdkPy', () => { }) expect(type).toBe('dict[str, Any] | str') // Both branches objects, and the same shape reached through an array: the - // marker is the CALL's className, so a propagated frame name (`Tool1`) does - // not revive class declaration on a walk that has nowhere to declare into. + // marker is the CALL's className, so a propagated frame name (`1`, the + // index-derived branch name) does not revive class declaration on a walk + // that has nowhere to declare into. const object = { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] } expect(jsonSchemaToPy({ oneOf: [object, object] })).toBe('dict[str, Any] | dict[str, Any]') expect(jsonSchemaToPy({ type: 'array', items: { oneOf: [object, { type: 'string' }] } })).toBe('list[dict[str, Any] | str]') From a525d7d1e237fe1476b4b452903c013ea52739be Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 16:46:23 +0800 Subject: [PATCH 062/190] test(tools): pin underscore-leading tool names to subscript access --- packages/core/tools/src/py-types.ts | 23 ++++++++++++++------- packages/core/tools/tests/py-types.spec.ts | 24 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 487ea9ae8d..0c088e708c 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -136,9 +136,13 @@ const MAX_CLASS_NAME_BASE = 120 * nested parentheses`), so an array chain deeper than that would render an SDK * block that is not valid Python at all — the same failure the docstring * escaping in {@link docLines} exists to prevent. 180 leaves headroom for the - * few brackets an annotation can add around the chain: `NotRequired[…]`, a - * `Literal[…]` item, and the `def` parameter list an argument annotation sits - * inside, for a worst case of 182. + * few brackets an annotation can add around the chain, all of which count + * toward the same limit: a `Literal[…]` item, plus exactly one of `NotRequired[…]` + * (a chain in a TypedDict field, whose class-body line has no other open + * bracket) or the `def` parameter list still open around a chain in a method's + * RETURN annotation — the two are mutually exclusive, so the worst case is 182. + * An argument annotation is always a bare TypedDict class name and opens + * nothing. * * A CPython grammar limit, not a deployment choice, so it is fixed rather than * configurable. The sibling `ts-types` renderer needs no counterpart: nothing @@ -557,10 +561,15 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { members.push(...doc) statements += 1 } else { - // Not a legal attribute name — the model reaches it via ``tools[name]``. - // The stub lists it as a subscript comment (referencing the named - // TypedDicts too) so a reader sees what is accessible; runtime resolution - // goes through the proxy's __getitem__. + // Not reachable as ``tools.name`` — the model reaches it via + // ``tools[name]``. Exotic names and hard keywords are not legal + // attributes at all; an underscore-leading name (``_foo``) IS a legal + // attribute and is routed here anyway, so one rule covers every + // underscore form rather than singling out the dunders that would + // name-mangle or resolve on ``object`` ahead of the proxy hook (see + // {@link RESERVED}). The stub lists it as a subscript comment + // (referencing the named TypedDicts too) so a reader sees what is + // accessible; runtime resolution goes through the proxy's __getitem__. members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`) const description = describe(schema) if (description !== undefined) members.push(`${pad(1)}# ${description}`) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 6dfbad9d0f..b60ee07e5e 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -465,7 +465,8 @@ describe('renderToolsSdkPy', () => { output: { type: 'string' }, } const text = renderToolsSdkPy([undescribedIdentifier, undescribedExotic]) - // Identifier method appears without a docstring line above it. + // Identifier method appears without a docstring in its body — hence the + // `: ...` stub, which a documented method replaces with the docstring. expect(text).toContain('async def plain(self, args: dict[str, Any]) -> str: ...') expect(text).not.toContain('"""') // Subscript entry appears without the "# ..." description follow-up. @@ -660,6 +661,27 @@ describe('renderToolsSdkPy', () => { expect(text).not.toContain('__debug__') }) + it('routes every underscore-leading tool name to subscript access', () => { + // `_foo` is a legal Python attribute, unlike an exotic name or a hard + // keyword, but the whole underscore family goes to `tools[name]` under one + // rule: `__meta__` resolves on `object` before the proxy's __getattr__ ever + // runs, and `__token` name-mangles at the CALL SITE inside the model's own + // class. `_foo` follows them so the rule needs no per-form exception. + const make = (name: string): ToolSdkSchema => ({ + name, + description: 'Leading underscore.', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>, + output: { type: 'string' }, + }) + const text = renderToolsSdkPy([make('_foo'), make('__meta__'), make('__token')]) + for (const name of ['_foo', '__meta__', '__token']) { + expect(text).toContain(`# tools[${JSON.stringify(name)}](args: dict[str, Any]) -> str`) + expect(text).not.toContain(`async def ${name}(`) + } + // No method emitted at all, so the class body needs the explicit `pass`. + expect(text).toContain(' pass\n') + }) + it('escapes quotes and backslashes in descriptions so the docstring stays valid Python', () => { // A description ending in `"` or an odd backslash would otherwise merge // with (or escape) the closing triple quote — and this block is Code From cb53dbe24a8180645321ce78fbefeffc217ddfb8 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 17:01:51 +0800 Subject: [PATCH 063/190] docs(tools): correct the bracket-count sites and the underscore routing rationale --- packages/core/tools/src/py-types.ts | 37 +++++++++++++++------- packages/core/tools/tests/py-types.spec.ts | 12 ++++--- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 0c088e708c..5ebfc516fb 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -31,8 +31,10 @@ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ * ABSENT: they are only special in statement position, so ``match: str`` as a * field and ``async def match(...)`` as a method are both legal, and including * them would needlessly degrade common search/regex tool fields to - * ``dict[str, Any]``. Underscore-leading names are handled separately (dunders - * name-mangle or resolve on ``object`` before the proxy hook), not here. + * ``dict[str, Any]``. Underscore-leading names are handled separately, not + * here: a non-dunder ``__token`` name-mangles, a dunder present on + * ``object``/``type`` resolves before the proxy hook, and implicit + * special-method lookup bypasses the hook. */ const RESERVED = new Set([ 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', @@ -137,12 +139,20 @@ const MAX_CLASS_NAME_BASE = 120 * block that is not valid Python at all — the same failure the docstring * escaping in {@link docLines} exists to prevent. 180 leaves headroom for the * few brackets an annotation can add around the chain, all of which count - * toward the same limit: a `Literal[…]` item, plus exactly one of `NotRequired[…]` - * (a chain in a TypedDict field, whose class-body line has no other open - * bracket) or the `def` parameter list still open around a chain in a method's - * RETURN annotation — the two are mutually exclusive, so the worst case is 182. - * An argument annotation is always a bare TypedDict class name and opens - * nothing. + * toward the same limit. Per emission site, counting brackets open at the + * chain's innermost point: + * + * - Return annotation, `async def f(self, args: X) -> chain:` — 180 `list[` + * plus an innermost `Literal[`. The parameter list's `(` closed at the `)` + * before the `->`, so it is NOT open here: 181. + * - TypedDict field, `field: NotRequired[chain]` — a class-body line with no + * other open bracket, and its children start at `listDepth: 1` to reserve + * the `NotRequired[`, so 179 `list[` plus `Literal[`: 181. + * - Argument annotation, `async def f(self, args: chain) -> Y:` — the `(` IS + * still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the + * worst case. Reachable only through a raw `register()` whose `parameters` + * is array-rooted; `defineTool` compiles an object root, so the annotation + * is a bare TypedDict class name that opens nothing. * * A CPython grammar limit, not a deployment choice, so it is fixed rather than * configurable. The sibling `ts-types` renderer needs no counterpart: nothing @@ -564,10 +574,13 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { // Not reachable as ``tools.name`` — the model reaches it via // ``tools[name]``. Exotic names and hard keywords are not legal // attributes at all; an underscore-leading name (``_foo``) IS a legal - // attribute and is routed here anyway, so one rule covers every - // underscore form rather than singling out the dunders that would - // name-mangle or resolve on ``object`` ahead of the proxy hook (see - // {@link RESERVED}). The stub lists it as a subscript comment + // attribute and is routed here anyway, because the forms that break + // split three ways — a non-dunder ``__token`` name-mangles at the CALL + // site, a dunder that exists on ``object``/``type`` (``__class__``, + // ``__doc__``) resolves before ``__getattr__`` ever runs, and implicit + // special-method lookup skips the hook entirely — and one rule over the + // whole family costs nothing while a per-form rule would have to + // enumerate them (see {@link RESERVED}). The stub lists it as a subscript comment // (referencing the named TypedDicts too) so a reader sees what is // accessible; runtime resolution goes through the proxy's __getitem__. members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index b60ee07e5e..cc7b55c4c1 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -662,11 +662,13 @@ describe('renderToolsSdkPy', () => { }) it('routes every underscore-leading tool name to subscript access', () => { - // `_foo` is a legal Python attribute, unlike an exotic name or a hard - // keyword, but the whole underscore family goes to `tools[name]` under one - // rule: `__meta__` resolves on `object` before the proxy's __getattr__ ever - // runs, and `__token` name-mangles at the CALL SITE inside the model's own - // class. `_foo` follows them so the rule needs no per-form exception. + // `_foo` and `__meta__` are both legal Python attributes, unlike an exotic + // name or a hard keyword, yet the whole underscore family goes to + // `tools[name]` under one rule. Only some forms actually break — `__token` + // name-mangles at the CALL SITE inside the model's own class, and a dunder + // that exists on `object` (`__class__`) resolves before the proxy's + // __getattr__ runs — so the family rule is what routes `_foo` and + // `__meta__`, not a defect in those two names. const make = (name: string): ToolSdkSchema => ({ name, description: 'Leading underscore.', From 137a2f4a4f95256e413d221d993c94a2ce5ec67d Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 17:15:35 +0800 Subject: [PATCH 064/190] docs(tools): name the underscore family in the Python SDK usage contract --- packages/core/tools/src/py-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 5ebfc516fb..f4091277f7 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -521,7 +521,7 @@ const SDK_INSTRUCTIONS = `## Writing code for run_code Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). Inside the program: -- Call tools as \`await tools.name(args)\` — subscript access for exotic names or reserved words: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON. +- Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON. - A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue. - Independent read-only calls MAY overlap under \`asyncio.gather\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`. - Emit the run's answer with \`print(...)\` and/or a top-level \`return <value>\`; the returned value must be lossless JSON. ONLY what you print and the returned value come back — intermediate tool results never enter the conversation, so extract just what you need. From bc94431c34588610777e6bf880eb6a7b3cb462b9 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 17:28:58 +0800 Subject: [PATCH 065/190] docs(tools): state the Python SDK declarations are static stubs A TypedDict reads as a constructible class, so a model that writes FooArgs(field=1) fails with NameError before dispatch: the run request injects only the tools namespace and ToolCallError. Say so in SDK_INSTRUCTIONS and require plain dict/list JSON arguments. The TS flavor needs no counterpart -- interface is visibly a type and its "runs type-stripped" clause already covers erasure. --- .../feature/2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../feature/2026-07-31-code-mode-language-dispatch.md | 2 +- .../feature/2026-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 17fbb7d6a9..c34c5166ae 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 5785565296cd06e8e1b4761969449e51d1e3af0d -2026-07-31-code-mode-language-dispatch.zh.md: 6e9d39bb117b2b18c0291bc047c4972e140a0b6e +2026-07-31-code-mode-language-dispatch.md: 2d9649b922157992c86e3421aeeac23a84a4edb4 +2026-07-31-code-mode-language-dispatch.zh.md: 61af77eb43d61061683f3ab6bf0d3c71587792a9 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 5785565296..2d9649b922 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -23,7 +23,7 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri ### The Python SDK renderer -`py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. Lexicographic means one ordered member stream: a tool whose name is not a legal attribute is listed as a `tools[name]` comment in its sorted position rather than partitioned to the end, matching how the TypeScript flavor quotes an exotic key in place. That stream forces one thing directly — comment lines are not statements, so a tool set that emits no method at all still needs an explicit `pass`. Two further rules are Python-specific rather than consequences of the ordering. A description becomes the method's docstring emitted as the FIRST statement of its body: above the `async def` the first one would document the `Tools` class and the rest would be dead expressions, leaving every method undocumented. And a `list[…]` chain degrades to `Any` past `MAX_LIST_NESTING`, because CPython's tokenizer rejects a line with more than 200 open brackets and the block must stay parseable Python — the same reason `docLines` escapes quotes and backslashes. `ts-types` needs neither: TypeScript attaches a leading `/** … */` to the member that follows it and bounds nesting nowhere in its grammar. +`py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. Lexicographic means one ordered member stream: a tool whose name is not a legal attribute is listed as a `tools[name]` comment in its sorted position rather than partitioned to the end, matching how the TypeScript flavor quotes an exotic key in place. That stream forces one thing directly — comment lines are not statements, so a tool set that emits no method at all still needs an explicit `pass`. Three further rules are Python-specific rather than consequences of the ordering. The usage contract states that the declarations are static stubs and arguments are plain `dict`/`list` values: a `TypedDict` reads as a constructible class, so a model that writes `FooArgs(field=1)` gets a `NameError` — TypeScript's `interface` is visibly a type, and the TS flavor's "runs type-stripped" clause already covers it. A description becomes the method's docstring emitted as the FIRST statement of its body: above the `async def` the first one would document the `Tools` class and the rest would be dead expressions, leaving every method undocumented. And a `list[…]` chain degrades to `Any` past `MAX_LIST_NESTING`, because CPython's tokenizer rejects a line with more than 200 open brackets and the block must stay parseable Python — the same reason `docLines` escapes quotes and backslashes. `ts-types` needs neither: TypeScript attaches a leading `/** … */` to the member that follows it and bounds nesting nowhere in its grammar. The standard that cap serves is grammatical validity, and the boundary is deliberate: a long `A | B | …` union is valid Python at any length and is left uncapped, even though CPython's `compile()` exhausts its C recursion walking the left-nested `BinOp` spine (measured on 3.9: 1,000 branches compile, 5,000 raise `RecursionError`). Nothing compiles this block — it is prompt text — so that limit costs nothing, whereas capping union length would retire the deep-chain tests that pin the walk's linear time and the class-name propagation cap. A future renderer that does need compilable output should flatten unions rather than truncate them. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 6e9d39bb11..61af77eb43 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -23,7 +23,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ### Python SDK 渲染器 -`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。这个成员流直接决定了一件事:注释行不是语句,所以一个不发出任何方法的工具集仍需显式 `pass`。另有两条规则并非源自排序,而是 Python 特有。其一,描述会成为方法的 docstring,且必须作为方法体的**第一条语句**发出:放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档。其二,`list[…]` 链超过 `MAX_LIST_NESTING` 后降级为 `Any`,因为 CPython 的 tokenizer 拒绝一行中超过 200 个同时未闭合的括号,而这个块必须是可解析的 Python——与 `docLines` 转义引号和反斜杠是同一个理由。`ts-types` 两者都不需要:TypeScript 会把前置的 `/** … */` 附着到其后的成员上,其语法也不对嵌套设限。 +`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。这个成员流直接决定了一件事:注释行不是语句,所以一个不发出任何方法的工具集仍需显式 `pass`。另有三条规则并非源自排序,而是 Python 特有。其一,用法约定声明这些声明只是静态存根、参数为普通 `dict`/`list` 值:`TypedDict` 读起来像一个可构造的类,模型若写 `FooArgs(field=1)` 会得到 `NameError`——TypeScript 的 `interface` 一眼就是类型,且 TS 形态的「runs type-stripped」一句已经覆盖了它。其二,描述会成为方法的 docstring,且必须作为方法体的**第一条语句**发出:放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档。其三,`list[…]` 链超过 `MAX_LIST_NESTING` 后降级为 `Any`,因为 CPython 的 tokenizer 拒绝一行中超过 200 个同时未闭合的括号,而这个块必须是可解析的 Python——与 `docLines` 转义引号和反斜杠是同一个理由。`ts-types` 两者都不需要:TypeScript 会把前置的 `/** … */` 附着到其后的成员上,其语法也不对嵌套设限。 该上限服务的标准是**语法合法性**,这条边界是有意划定的:长的 `A | B | …` union 在任何长度下都是合法 Python,故不设上限——尽管 CPython 的 `compile()` 在沿左嵌套 `BinOp` 脊柱下降时会耗尽 C 递归(在 3.9 上实测:1,000 个分支可编译,5,000 个抛 `RecursionError`)。没有任何东西会编译这个块——它是提示词文本——所以那条限制在这里没有代价;而给 union 长度封顶会作废那几个钉住 walk 线性时间与类名传播上限的深链测试。将来若有渲染器确实需要可编译的输出,应当把 union 拍平,而不是截断。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index f4091277f7..3f3707b4eb 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -519,7 +519,7 @@ export function jsonSchemaToPy(schema: unknown): string { /** The fixed model-facing usage contract rendered above the declarations. */ const SDK_INSTRUCTIONS = `## Writing code for run_code -Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). Inside the program: +Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). Everything declared below is a STATIC STUB describing shapes: the \`TypedDict\` classes are NOT bound at run time, so build arguments as plain \`dict\`/\`list\` JSON values — \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: - Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON. - A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue. From 1b4cb031f0ff195f155c41ad719bfef5715c890b Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 17:45:38 +0800 Subject: [PATCH 066/190] fix(tools): name the two bound SDK names and escape NEL The static-stub sentence over-generalized: `tools` and `ToolCallError` ARE bound at run time, and a model reading "everything below is a stub" could stop catching `ToolCallError`. State the boundary and pin both halves in the fixed-instruction assertions. UNPRINTABLE missed U+0085: it is Cc but not ECMAScript whitespace, so it survived the collapse and reached the docstring raw and invisible. Add it and scope the docstring to Cc, since the `\xNN` escape cannot address the Cf formatting characters that pass through by design. Record the backend PR's two runtime contracts -- inject only `tools` and `ToolCallError`, and bind the assembly-time language to the request -- in the Agent Note and at requireCodeRuntime. --- ...07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 2 ++ ...026-07-31-code-mode-language-dispatch.zh.md | 2 ++ packages/core/tools/src/index.ts | 8 ++++++++ packages/core/tools/src/py-types.ts | 18 ++++++++++++++---- packages/core/tools/tests/py-types.spec.ts | 18 ++++++++++++++++++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index c34c5166ae..5cafc77562 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 2d9649b922157992c86e3421aeeac23a84a4edb4 -2026-07-31-code-mode-language-dispatch.zh.md: 61af77eb43d61061683f3ab6bf0d3c71587792a9 +2026-07-31-code-mode-language-dispatch.md: cbcc8eb54ce78b922e584d050bb9d6a73439a08c +2026-07-31-code-mode-language-dispatch.zh.md: 5502daf926a62fa2b6981457be8f2b5583f477b8 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 2d9649b922..cbcc8eb54c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -40,3 +40,5 @@ The standard that cap serves is grammatical validity, and the boundary is delibe Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. + +Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 61af77eb43..5502daf926 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -40,3 +40,5 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd 新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 + +Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5c523af878..820390228e 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -836,6 +836,14 @@ export class ToolRegistry extends Service { * behind it — hostage to a code runtime existing even under `mode: * 'native'` (the loop's optional-backend idiom, same as * `sessionPersistence`). + * + * Assembly and `run_code` execution read separately, so the language is not + * bound to a request. Harmless while one published backend exists — both + * reads return the same flavor — but a reload that swapped in a second + * language between them would hand a program written against one SDK to the + * other. Binding it belongs to the PR that publishes that backend, which is + * also the first point it can be tested; recorded in the + * [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md). */ private requireCodeRuntime(): CodeRuntime { const runtime = this.ctx.get('codeRuntime') diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 3f3707b4eb..d85660ee86 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -70,15 +70,25 @@ interface RenderState { } /** - * Control characters that survive the whitespace collapse in {@link describe} - * and have no printable form. CPython rejects source containing a NUL outright + * The `Cc` code points that survive the whitespace collapse in {@link describe} + * and have no printable form: the C0 controls, DEL, and NEL. U+0009 to U+000D + * are absent because ECMAScript `\s` already collapsed them; U+0085 is `Cc` but + * NOT in `\s` (TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS), so it survives and + * is escaped here. CPython rejects source containing a NUL outright * (`SyntaxError: source code string cannot contain null bytes`), whether it * sits in a docstring or in a comment, so one such byte anywhere in a schema * description would make the whole generated SDK unparseable — the model's only * declaration of the tools. The rest are legal but invisible; escaping them * with the same rule keeps the emitted text readable and the treatment uniform. + * + * The set stops at `Cc` because the escape is `\xNN`, which addresses exactly + * U+0000 to U+00FF. The invisible `Cf` formatting characters (U+00AD soft + * hyphen, U+200B ZWSP, U+200E/U+200F bidi marks, U+2060 word joiner) pass + * through by design: covering them would need a second `\uNNNN` escape form, + * and they are legal in both consumers — only LF and CR terminate a Python + * string literal or a `#` comment. */ -const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f]/g +const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f\u0085]/g /** * The collapsed one-line `description` of a schema node (byte-stable across @@ -519,7 +529,7 @@ export function jsonSchemaToPy(schema: unknown): string { /** The fixed model-facing usage contract rendered above the declarations. */ const SDK_INSTRUCTIONS = `## Writing code for run_code -Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). Everything declared below is a STATIC STUB describing shapes: the \`TypedDict\` classes are NOT bound at run time, so build arguments as plain \`dict\`/\`list\` JSON values — \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: +Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing shapes — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: - Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON. - A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue. diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index cc7b55c4c1..158c043909 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -106,6 +106,12 @@ describe('renderToolsSdkPy', () => { expect(text).toContain('# tools["class"](args: dict[str, Any]) -> str') // Fixed instruction lines the model relies on. expect(text).toContain('top-level `await`') + // The binding boundary: `tools`/`ToolCallError` are bound, the TypedDicts + // are not. Both halves are pinned — dropping either one turns a correct + // contract into a wrong one (a model that reads only "STATIC STUB" would + // stop catching `ToolCallError`). + expect(text).toContain('exactly two of the names declared below are bound: `tools` and `ToolCallError`') + expect(text).toContain('never `FooArgs(field=1)`, which raises `NameError`') expect(text).toContain('ToolCallError') expect(text).toContain('class ToolCallError(Exception):') expect(text).toContain('MAY overlap under `asyncio.gather`') @@ -732,5 +738,17 @@ describe('renderToolsSdkPy', () => { const others = renderToolsSdkPy([make('bell\u0007esc\u001bdel\u007f')]) expect(others).toContain(String.raw`bell\x07esc\x1bdel\x7f`) expect(renderToolsSdkPy([make('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""') + // NEL is the one `Cc` code point the collapse does NOT fold: ECMAScript + // whitespace is TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS, and U+0085 is + // in none of them, so without the escape it would reach the docstring raw + // and be invisible there. NBSP, which IS whitespace, folds instead. + const nel = renderToolsSdkPy([make('a\u0085b')]) + expect(nel).not.toContain('\u0085') + expect(nel).toContain(String.raw`# a\x85b`) + expect(renderToolsSdkPy([make('nb\u00a0sp')])).toContain('"""nb sp"""') + // `Cf` formatting characters pass through by design: `\xNN` cannot address + // them, and they terminate neither a Python string literal nor a `#` + // comment, so the block stays parseable with the code point intact. + expect(renderToolsSdkPy([make('zero\u200bwidth')])).toContain('"""zero\u200bwidth"""') }) }) From 308f5ae0f35548946e4db8f2f05de1822394876f Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 17:59:48 +0800 Subject: [PATCH 067/190] fix(tools): escape the whole C1 control range, not just NEL Unicode Cc is U+0000-U+001F plus U+007F-U+009F, and no C1 code point is ECMAScript whitespace, so U+0080-U+009F all survived the collapse and reached the docstring raw and invisible -- the gap the previous commit closed for NEL alone. \xNN addresses the whole block, which is the same reason the set stops at Cc, so widen the class to U+009F and pin U+009B/U+009C/U+009F. Windows-1252 bytes 0x80-0x9F decoded as Latin-1 produce exactly these. Also: required TypedDict fields share the optional fields' listDepth start, and a description of whitespace plus a surviving control character is not absent. --- packages/core/tools/src/py-types.ts | 32 ++++++++++++++-------- packages/core/tools/tests/py-types.spec.ts | 11 +++++--- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index d85660ee86..952437eaa7 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -71,10 +71,13 @@ interface RenderState { /** * The `Cc` code points that survive the whitespace collapse in {@link describe} - * and have no printable form: the C0 controls, DEL, and NEL. U+0009 to U+000D - * are absent because ECMAScript `\s` already collapsed them; U+0085 is `Cc` but - * NOT in `\s` (TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS), so it survives and - * is escaped here. CPython rejects source containing a NUL outright + * and have no printable form: the C0 controls, DEL, and the C1 controls. Only + * U+0009 to U+000D are absent, because ECMAScript `\s` already collapsed them — + * `\s` is TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS, so no C1 code point is + * in it and the whole U+0080 to U+009F block reaches this rule intact. Those + * are not hypothetical input: they are what Windows-1252 bytes 0x80 to 0x9F + * (smart quotes, em dash) become when decoded as Latin-1. + * CPython rejects source containing a NUL outright * (`SyntaxError: source code string cannot contain null bytes`), whether it * sits in a docstring or in a comment, so one such byte anywhere in a schema * description would make the whole generated SDK unparseable — the model's only @@ -82,13 +85,14 @@ interface RenderState { * with the same rule keeps the emitted text readable and the treatment uniform. * * The set stops at `Cc` because the escape is `\xNN`, which addresses exactly - * U+0000 to U+00FF. The invisible `Cf` formatting characters (U+00AD soft - * hyphen, U+200B ZWSP, U+200E/U+200F bidi marks, U+2060 word joiner) pass - * through by design: covering them would need a second `\uNNNN` escape form, - * and they are legal in both consumers — only LF and CR terminate a Python - * string literal or a `#` comment. + * U+0000 to U+00FF: the whole `Cc` block fits, and the invisible `Cf` + * formatting characters (U+00AD soft hyphen, U+200B ZWSP, U+200E/U+200F bidi + * marks, U+2060 word joiner) do not. `Cf` therefore passes through by design — + * covering it would need a second `\uNNNN` escape form, and it is legal in both + * consumers, since only LF and CR terminate a Python string literal or a `#` + * comment. */ -const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f\u0085]/g +const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g /** * The collapsed one-line `description` of a schema node (byte-stable across @@ -97,7 +101,9 @@ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f\u0085]/g * so only the description field needs guarding. A description that collapses * to nothing (empty, or whitespace only) is `undefined` too: it documents the * node no better than an absent one, and emitting it would leave an empty - * `"""` docstring or a bare `# ` line in the SDK. + * `"""` docstring or a bare `# ` line in the SDK. Only ECMAScript whitespace + * folds, so a description of whitespace plus one surviving control character is + * NOT absent: it collapses to that character's visible escape. * * Control characters left over after the whitespace collapse are rendered as * their `\xNN` escapes (see {@link UNPRINTABLE}); the escape's own backslash is @@ -157,7 +163,9 @@ const MAX_CLASS_NAME_BASE = 120 * before the `->`, so it is NOT open here: 181. * - TypedDict field, `field: NotRequired[chain]` — a class-body line with no * other open bracket, and its children start at `listDepth: 1` to reserve - * the `NotRequired[`, so 179 `list[` plus `Literal[`: 181. + * the `NotRequired[`, so 179 `list[` plus `Literal[`: 181. Required fields + * share that start for uniformity, spending one level of representable depth + * on a bracket they never emit. * - Argument annotation, `async def f(self, args: chain) -> Y:` — the `(` IS * still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the * worst case. Reachable only through a raw `register()` whose `parameters` diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 158c043909..78bcab6d23 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -738,13 +738,16 @@ describe('renderToolsSdkPy', () => { const others = renderToolsSdkPy([make('bell\u0007esc\u001bdel\u007f')]) expect(others).toContain(String.raw`bell\x07esc\x1bdel\x7f`) expect(renderToolsSdkPy([make('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""') - // NEL is the one `Cc` code point the collapse does NOT fold: ECMAScript - // whitespace is TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS, and U+0085 is - // in none of them, so without the escape it would reach the docstring raw - // and be invisible there. NBSP, which IS whitespace, folds instead. + // No C1 control is ECMAScript whitespace (TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus + // LF/CR/LS/PS), so the collapse folds none of U+0080 to U+009F and the + // escape is what keeps them out of the docstring, where they would be + // invisible. NBSP, which IS whitespace, folds instead. Windows-1252 bytes + // 0x80 to 0x9F decoded as Latin-1 land exactly here. const nel = renderToolsSdkPy([make('a\u0085b')]) expect(nel).not.toContain('\u0085') expect(nel).toContain(String.raw`# a\x85b`) + const c1 = renderToolsSdkPy([make('csi\u009bst\u009cend\u009f')]) + expect(c1).toContain(String.raw`csi\x9bst\x9cend\x9f`) expect(renderToolsSdkPy([make('nb\u00a0sp')])).toContain('"""nb sp"""') // `Cf` formatting characters pass through by design: `\xNN` cannot address // them, and they terminate neither a Python string literal nor a `#` From cf85c9a3e46c15c04bb3b28b5fca2af7c94957af Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 18:21:43 +0800 Subject: [PATCH 068/190] fix(tools): escape unpaired surrogates and state the Cf boundary by category U+00AD is 0xAD, so "Cf cannot be addressed by \xNN" was false for the first example in its own list. The real boundary is the category: one \xNN form covers Cc exactly, and escaping the single addressable Cf member would leave a rule that is neither category- nor addressability-shaped. A lone surrogate is the NUL case rather than the invisible-character case -- Python source must be UTF-8-encodable, and compile() raises UnicodeEncodeError for one in a string literal or a # comment alike (measured on 3.9). JSON.parse on a wire "\ud800" escape produces them, so escape them as \uNNNN; the regex's u flag keeps well-formed astral pairs intact. Pin the whitespace-plus-surviving-control boundary, which also pins trim-after-escape. --- packages/core/tools/src/py-types.ts | 34 ++++++++--- packages/core/tools/tests/py-types.spec.ts | 70 +++++++++++++--------- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 952437eaa7..a74729d9ff 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -84,16 +84,32 @@ interface RenderState { * declaration of the tools. The rest are legal but invisible; escaping them * with the same rule keeps the emitted text readable and the treatment uniform. * - * The set stops at `Cc` because the escape is `\xNN`, which addresses exactly - * U+0000 to U+00FF: the whole `Cc` block fits, and the invisible `Cf` - * formatting characters (U+00AD soft hyphen, U+200B ZWSP, U+200E/U+200F bidi - * marks, U+2060 word joiner) do not. `Cf` therefore passes through by design — - * covering it would need a second `\uNNNN` escape form, and it is legal in both - * consumers, since only LF and CR terminate a Python string literal or a `#` - * comment. + * The boundary is the category, not per-code-point addressability: `\xNN` + * addresses U+0000 to U+00FF, so one escape form covers `Cc` exactly. The + * invisible `Cf` formatting characters pass through by design — of them only + * U+00AD soft hyphen would fit `\xNN` at all, and escaping that one while + * U+200B ZWSP, U+200E/U+200F bidi marks, and U+2060 word joiner passed through + * would leave a rule that is neither category- nor addressability-shaped. The + * whole family is legal in both consumers, since only LF and CR terminate a + * Python string literal or a `#` comment. */ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g +/** + * Unpaired surrogate code points, escaped by {@link describe} as `\uNNNN` — + * its own form, since `\xNN` stops at U+00FF. The `u` flag is what makes this + * the LONE ones: in Unicode mode a well-formed pair is a single astral code + * point outside D800 to DFFF, so an emoji in a description survives untouched. + * + * This is the NUL case from {@link UNPRINTABLE}, not the invisible-character + * case. Python source must be UTF-8-encodable and a lone surrogate is not, so + * `compile()` raises `UnicodeEncodeError: surrogates not allowed` for one + * anywhere in the text — measured on 3.9 for a string literal and for a `#` + * comment alike. A raw or MCP tool description reaches this: `JSON.parse` on a + * wire `"\ud800"` escape yields exactly such a code point. + */ +const LONE_SURROGATE = /[\ud800-\udfff]/gu + /** * The collapsed one-line `description` of a schema node (byte-stable across * formatting churn), or `undefined` when the node carries none. Every caller @@ -106,7 +122,8 @@ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g * NOT absent: it collapses to that character's visible escape. * * Control characters left over after the whitespace collapse are rendered as - * their `\xNN` escapes (see {@link UNPRINTABLE}); the escape's own backslash is + * their `\xNN` escapes (see {@link UNPRINTABLE}) and unpaired surrogates as + * their `\uNNNN` escapes (see {@link LONE_SURROGATE}); the escape's own backslash is * emitted literally by both consumers, since {@link docLines} doubles it into a * Python source escape and a `#` comment carries it verbatim. */ @@ -116,6 +133,7 @@ function describe(schema: object): string | undefined { const collapsed = description .replace(/\s+/g, ' ') .replace(UNPRINTABLE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`) + .replace(LONE_SURROGATE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`) .trim() return collapsed.length === 0 ? undefined : collapsed } diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 78bcab6d23..61379d22ce 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -94,6 +94,15 @@ describe('renderToolsSdkPy', () => { parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>, output: { type: 'string' }, } + /** One tool carrying `description` at both emission sites: the method docstring and the field comment. */ + const described = (description: string): ToolSdkSchema => ({ + name: 'weird', + description, + parameters: parameterSchemaSpecToJsonSchema({ + field: { type: 'string', required: true, description }, + }) as unknown as Record<string, unknown>, + output: { type: 'string' }, + }) it('declares identifier tools as async methods and lists exotic/reserved names as subscript comments', () => { const text = renderToolsSdkPy([exotic, bash, reserved]) @@ -694,17 +703,11 @@ describe('renderToolsSdkPy', () => { // A description ending in `"` or an odd backslash would otherwise merge // with (or escape) the closing triple quote — and this block is Code // Mode's only SDK, so it must always parse. - const make = (description: string): ToolSdkSchema => ({ - name: 'weird', - description, - parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>, - output: { type: 'string' }, - }) - const trailingQuote = renderToolsSdkPy([make('ends in a quote"')]) + const trailingQuote = renderToolsSdkPy([described('ends in a quote"')]) expect(trailingQuote).toContain(String.raw`"""ends in a quote\""""`) - const trailingBackslash = renderToolsSdkPy([make('ends in a backslash\\')]) + const trailingBackslash = renderToolsSdkPy([described('ends in a backslash\\')]) expect(trailingBackslash).toContain(String.raw`"""ends in a backslash\\"""`) - const tripleQuote = renderToolsSdkPy([make('contains """ triple quote')]) + const tripleQuote = renderToolsSdkPy([described('contains """ triple quote')]) expect(tripleQuote).toContain(String.raw`"""contains \"\"\" triple quote"""`) }) @@ -716,15 +719,7 @@ describe('renderToolsSdkPy', () => { // from parsing at all. The whitespace collapse does not remove it (a NUL is // not whitespace). Rendering it as a visible escape keeps the source // parseable and still shows the model what the schema said. - const make = (description: string): ToolSdkSchema => ({ - name: 'weird', - description, - parameters: parameterSchemaSpecToJsonSchema({ - field: { type: 'string', required: true, description }, - }) as unknown as Record<string, unknown>, - output: { type: 'string' }, - }) - const nul = renderToolsSdkPy([make('before\u0000after')]) + const nul = renderToolsSdkPy([described('before\u0000after')]) // Both emission sites: the method docstring and the `#` field comment. The // docstring's backslash is doubled by the same escaping that keeps a literal // backslash from escaping the closing triple quote, so Python parses it back @@ -735,23 +730,44 @@ describe('renderToolsSdkPy', () => { // The other C0 controls and DEL escape on the same path. Tab, newline and // carriage return never reach it: the whitespace collapse folds them to a // space first. - const others = renderToolsSdkPy([make('bell\u0007esc\u001bdel\u007f')]) + const others = renderToolsSdkPy([described('bell\u0007esc\u001bdel\u007f')]) expect(others).toContain(String.raw`bell\x07esc\x1bdel\x7f`) - expect(renderToolsSdkPy([make('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""') + expect(renderToolsSdkPy([described('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""') // No C1 control is ECMAScript whitespace (TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus // LF/CR/LS/PS), so the collapse folds none of U+0080 to U+009F and the // escape is what keeps them out of the docstring, where they would be // invisible. NBSP, which IS whitespace, folds instead. Windows-1252 bytes // 0x80 to 0x9F decoded as Latin-1 land exactly here. - const nel = renderToolsSdkPy([make('a\u0085b')]) + const nel = renderToolsSdkPy([described('a\u0085b')]) expect(nel).not.toContain('\u0085') expect(nel).toContain(String.raw`# a\x85b`) - const c1 = renderToolsSdkPy([make('csi\u009bst\u009cend\u009f')]) + const c1 = renderToolsSdkPy([described('csi\u009bst\u009cend\u009f')]) expect(c1).toContain(String.raw`csi\x9bst\x9cend\x9f`) - expect(renderToolsSdkPy([make('nb\u00a0sp')])).toContain('"""nb sp"""') - // `Cf` formatting characters pass through by design: `\xNN` cannot address - // them, and they terminate neither a Python string literal nor a `#` - // comment, so the block stays parseable with the code point intact. - expect(renderToolsSdkPy([make('zero\u200bwidth')])).toContain('"""zero\u200bwidth"""') + expect(renderToolsSdkPy([described('nb\u00a0sp')])).toContain('"""nb sp"""') + // `Cf` formatting characters pass through by category, not by + // addressability — U+00AD would fit `\xNN`, the rest would need a second + // form. They terminate neither a Python string literal nor a `#` comment, + // so the block stays parseable with the code point intact. + expect(renderToolsSdkPy([described('zero\u200bwidth')])).toContain('"""zero\u200bwidth"""') + // Whitespace around a surviving control character is not an absent + // description: the escape runs before the trim, so what is left is visible. + expect(renderToolsSdkPy([described(' \u0085 ')])).toContain(String.raw`# \x85`) + }) + + it('escapes unpaired surrogates, which make the source impossible to encode', () => { + // This is the NUL case, not the invisible-character case: Python source + // must be UTF-8-encodable, and `compile()` raises `UnicodeEncodeError: + // surrogates not allowed` for a lone surrogate in a string literal and in + // a `#` comment alike, so one would stop this block — Code Mode's only SDK + // — from parsing. A wire description reaches it: `JSON.parse` on a + // `"\ud800"` escape yields exactly this code point. + const high = renderToolsSdkPy([described('a\ud800b')]) + expect(high).not.toContain('\ud800') + expect(high).toContain(String.raw`# a\ud800b`) + // A lone LOW surrogate is just as unencodable, and `\xNN` reaches neither. + expect(renderToolsSdkPy([described('a\udfffb')])).toContain(String.raw`# a\udfffb`) + // A well-formed pair is ONE astral code point, not two surrogates — the + // regex's `u` flag is what draws that line, so an emoji survives intact. + expect(renderToolsSdkPy([described('emoji \u{1f600} ok')])).toContain('"""emoji \u{1f600} ok"""') }) }) From d7a162fc6629d4b0d6272d10dd28cd17e5e260de Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Tue, 4 Aug 2026 15:02:17 +0800 Subject: [PATCH 069/190] fix(web): stop the conversation column from scrolling sideways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero's backdrop ellipse is sized 1051/776 of the hero box so its blur scales with the input card, which means it reaches past the column whenever the column is narrower than the glow. `[data-conversation-scroll]` declared only `overflow-y: auto`, and a box that scrolls in one axis computes the other axis's initial `visible` to `auto` — so that bleed came back as a real horizontal scrollbar, 24–95px of range across ordinary laptop widths. Declare `overflow-x: hidden` on the column instead of leaving the second axis to be derived. Clipping is unchanged (the box already clipped both axes); the declaration withdraws only the bar and the user gesture. --- ...versation-column-one-axis-scroll.i18n.yaml | 6 + ...-04-conversation-column-one-axis-scroll.md | 37 +++ ...-conversation-column-one-axis-scroll.zh.md | 37 +++ .../tests/conversation-column-overflow.e2e.ts | 285 ++++++++++++++++++ .../geometry.expected.md | 9 + apps/web/tsconfig.json | 1 + .../skeleton/ConversationRoot.module.css | 8 + tsconfig.host.json | 1 + 8 files changed, 384 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md create mode 100644 apps/web/tests/conversation-column-overflow.e2e.ts create mode 100644 apps/web/tests/snapshots/conversation-column-overflow/geometry.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml new file mode 100644 index 0000000000..47a432a7dd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +2026-08-04-conversation-column-one-axis-scroll.md: fa2347e5e1b8d41da020db69840e1dcf32cfc4c3 +2026-08-04-conversation-column-one-axis-scroll.zh.md: aba34e304b8e6a349bd1e295ceb00d5ad809f6dd diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md new file mode 100644 index 0000000000..fa2347e5e1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -0,0 +1,37 @@ +# Agent Note: The conversation column scrolls on one axis + +Status: implemented + +English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md) + +## Problem + +Narrowing the center column — by the window or by the sidebar drag — put a horizontal scrollbar under the whole conversation column on the hero. The bleeding element is the hero's decorative backdrop ellipse: `.heroGlow` is sized `1051/776` of the hero box so its blur scales in userSpace with the input card, which means it reaches past the column whenever the column is narrower than the glow. + +That bleed is by construction and stays. What made it user-visible is the scroll container it sits in. `[data-conversation-scroll]` declared `overflow-y: auto` and left the other axis at its initial `visible`, and a box that scrolls in one axis computes `visible` to `auto` in the other. Every column narrower than the glow therefore offered a real horizontal scroll range — measured at 24–95px across the widths a laptop actually produces. + +## Decision + +`.scrollBody` declares `overflow-x: hidden`. The column states that it is a one-axis scroller instead of leaving the second axis to be derived. + +Clipping does not change. `overflow-y: auto` had already made the box a scroll container that clips both axes, so the declaration withdraws only the scrollbar and the user gesture; the glow keeps its bleed, its blur radius, and the same painted extent, and the column keeps its vertical scroll. Nothing in the composer chain moves. + +## Alternatives considered + +**Size the glow to fit the column.** Rejected. The glow's width is what scales its `stdDeviation="50"` blur with the input card (figma 313:14109); constraining it would make the blur tighten as the column narrows, which is a visual regression to fix a scrollbar. + +**Wrap the glow in a clipping box.** Rejected. It adds a box whose only job is to undo an overflow the column already clips, and it leaves the derived `overflow-x: auto` in place for the next element that bleeds — the transcript is full of candidates. + +**Rely on the frame's `.centerCol { overflow: hidden }`.** It cannot help. That clip is outside the scroll container, so it hides the glow's overhang at the column border while the container inside it still scrolls to reach it. The reported bar was that container's. + +**Assert `scrollWidth === clientWidth` in the test.** Rejected as the signal, because it does not distinguish the states: `hidden` clips the bleed rather than reflowing it away, so the scroll range reads the same on both sides of the fix. Only refusing a user gesture differs, which is what the scenario measures. + +## Testing + +[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all. + +Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to the full bleed — without it a `scrollLeft` of 0 could equally mean the wheel never arrived. + +## Consequences + +The conversation column no longer offers a horizontal scrollbar at any width, and decorative bleed in the composer chain is now clipped rather than exposed as scroll range. The cost is that genuinely wide content under this column is clipped instead of reachable by scrolling: any such surface owns its own scroller, as the markdown code block and the trajectory table already do. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md new file mode 100644 index 0000000000..aba34e304b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -0,0 +1,37 @@ +# Agent Note:会话列只在一个轴上滚动 + +状态:已实现 + +[English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文 + +## 问题 + +当中间列被拉窄——无论是拖窗口还是拖侧边栏——hero 态的整条会话列下方就会出现一条横向滚动条。溢出的元素是 hero 的装饰性背景椭圆:`.heroGlow` 的宽度取 hero 盒子的 `1051/776`,好让它的模糊在 userSpace 中随输入卡片一同缩放;这也意味着只要列比它窄,它就会伸出列外。 + +这处外溢是设计使然,保持不变。真正让它对用户可见的是它所处的滚动容器。`[data-conversation-scroll]` 只声明了 `overflow-y: auto`,另一个轴留在初始值 `visible`;而一个在某一轴上滚动的盒子,会把另一轴的 `visible` 计算为 `auto`。于是每一条比该椭圆窄的列都真的给出了一段横向滚动范围——在笔记本实际会产生的几档宽度上,实测为 24–95px。 + +## 决定 + +`.scrollBody` 声明 `overflow-x: hidden`。这条列明确声明自己是单轴滚动容器,而不是把第二个轴交给推导。 + +裁剪行为不变。`overflow-y: auto` 早已使该盒子成为在两个轴上都裁剪的滚动容器,因此这条声明收回的只是滚动条和用户手势;椭圆保留它的外溢、模糊半径和同样的绘制范围,列也保留纵向滚动。输入区那条链路上没有任何东西移动。 + +## 曾考虑的替代方案 + +**把椭圆缩到列内。** 否决。椭圆的宽度正是让它 `stdDeviation="50"` 的模糊随输入卡片缩放的依据(figma 313:14109);约束宽度会使列越窄模糊越紧,等于为修一条滚动条而制造一处视觉回归。 + +**给椭圆套一层裁剪盒。** 否决。这层盒子唯一的职责是抵消列本就会裁剪的溢出,而推导出的 `overflow-x: auto` 仍然留在原处,等着下一个外溢的元素——会话流里这样的候选者不少。 + +**依赖外框的 `.centerCol { overflow: hidden }`。** 它帮不上忙。那处裁剪在滚动容器之外,只能在列边界处遮住椭圆探出的部分,而里面的容器照样可以滚过去够到它。用户报告的那条滚动条属于内层容器。 + +**在测试里断言 `scrollWidth === clientWidth`。** 作为判据被否决,因为它区分不出两种状态:`hidden` 裁剪外溢,而不是把它重排掉,所以修复前后读到的滚动范围一样。唯一有差别的是拒绝用户手势,这正是该场景所测量的。 + +## 测试 + +[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上向列横向滚轮并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。 + +两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到完整的外溢量——没有它,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。 + +## 后果 + +会话列在任何宽度下都不再给出横向滚动条,输入区链路上的装饰性外溢从暴露为滚动范围改为被裁剪。代价是这条列下真正过宽的内容会被裁掉而非可滚动够到:这类界面各自拥有自己的滚动容器,markdown 代码块和轨迹表格已经如此。 diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts new file mode 100644 index 0000000000..e676a7df94 --- /dev/null +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -0,0 +1,285 @@ +// Web e2e scenario: the conversation column scrolls on one axis only, as the +// browser actually lays it out. The reported symptom was a horizontal +// scrollbar under the whole center column once the window (or the sidebar +// drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the +// column and becoming user-scrollable. +// +// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the +// hero box (ConversationRoot.module.css) so the blur scales with the input +// card. What changed is the scroll container: `[data-conversation-scroll]` +// scrolls vertically, and a box that scrolls in one axis computes the other +// axis's initial `visible` to `auto`, so the bleed came back as a bar. The +// fix states `overflow-x: hidden` there. +// +// Only a real engine reports that pair — the bleed and the resulting scroll +// range — so the scenario sweeps viewport widths that bracket the glow's +// width and asserts both at each stop. Asserting no horizontal scroll alone +// would go vacuous the moment the glow stopped bleeding for an unrelated +// reason, which is why each stop also records whether it bleeds; the wide stop +// is the control where it does not. +// +// Zero model calls: the hero is the boot state, so nothing is seeded and no +// replay row mounts. A stray stream would fail loud with NO_ADAPTER. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-overflow', import.meta.url)) +/** + * Committed golden of the one-axis relation at every stop. It records + * relations and booleans, never absolute coordinates: the column width follows + * the viewport and the sidebar, and a golden carrying pixels would document the + * platform instead of the change. + */ +const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') +const MODE = webSnapshotMode() +/** + * Viewport widths bracketing the glow. The hero box is `min(776, column - 48)` + * and the glow is 1051/776 of it, so every stop under a ~1051px column bleeds + * and the widest one does not — the sweep therefore covers both sides of the + * relation rather than sampling one comfortable width. + */ +const WIDTHS = [1680, 1200, 1000, 800, 600] +/** Element id of the mutation control's injected sheet, so the test can take it back out. */ +const CONTROL_STYLE_ID = 'dsh-column-overflow-control' + +/** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */ +interface ColumnMetrics { + /** Viewport width the stop was measured at. */ + width: number + /** The column's content width. Not committed to the golden — it is what settles after a resize, and what the sweep waits on. */ + columnWidth: number + /** Resolved `overflow-x` on the conversation scroll container. */ + overflowX: string + /** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */ + glowBleeds: boolean + /** + * `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and + * `auto` both report the same value, because `hidden` clips the bleed rather + * than reflowing it away. Recorded because it is the vacuity guard in + * numbers — it must stay positive at the narrow stops, or the scenario has + * stopped reproducing the situation the fix is for. + */ + bleedRange: number + /** True when the column still scrolls vertically — the axis the fix must not take away. */ + scrollsVertically: boolean +} + +/** + * Measure the conversation column at the page's current viewport. + * @param page - the page under test. + * @param width - the viewport width already applied, recorded with the reading. + * @returns the stop's overflow relations. + */ +function measureColumn(page: Page, width: number): Promise<ColumnMetrics> { + return page.evaluate((viewportWidth) => { + const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]') + if (scroller === null) throw new Error('conversation scroll container not in the DOM') + const glow = scroller.querySelector<SVGElement>('[class*="heroGlow"]') + if (glow === null) throw new Error('hero glow not in the DOM — the boot state is not the hero') + const box = scroller.getBoundingClientRect() + const glowBox = glow.getBoundingClientRect() + return { + width: viewportWidth, + columnWidth: scroller.clientWidth, + overflowX: getComputedStyle(scroller).overflowX, + // `clientWidth` is the content edge, which is what the scrollable + // overflow region is measured against; either side counts as a bleed, + // though only the right one can produce a bar in this writing mode. + glowBleeds: glowBox.right > box.left + scroller.clientWidth + 0.5 || glowBox.left < box.left - 0.5, + bleedRange: scroller.scrollWidth - scroller.clientWidth, + scrollsVertically: getComputedStyle(scroller).overflowY === 'auto', + } + }, width) +} + +/** + * Scroll the column sideways the way a user would and report where it landed. + * + * This is the one signal that separates the two states, and it is why the + * scenario needs a real engine: `overflow-x: hidden` leaves the box + * programmatically scrollable and leaves `scrollWidth` untouched, so every + * property reading agrees across the fix. Only refusing an actual input event + * differs — measured at the 1200px stop, the shipped column stays at 0 while + * the same page with `overflow-x: auto` forced on lands at the full 66px bleed. + * @param page - the page under test. + * @returns `scrollLeft` after one horizontal wheel over the column. + */ +async function wheelHorizontally(page: Page): Promise<number> { + const origin = await page.evaluate(() => { + const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]') + if (scroller === null) throw new Error('conversation scroll container not in the DOM') + // Start from the origin so the reading is this gesture's own effect. + scroller.scrollLeft = 0 + const box = scroller.getBoundingClientRect() + // Near the top of the column, clear of the centered hero card: the wheel + // must reach the column, not a nested scroller the composer owns. + return { x: box.left + box.width / 2, y: box.top + 60 } + }) + await page.mouse.move(origin.x, origin.y) + await page.mouse.wheel(300, 0) + // Two frames: the scroll applies during the frame the wheel is dispatched + // into, and is readable in the next. Polling for a settled value cannot be + // used here — the value under test is 0, which a poll starting at 0 accepts + // before the gesture has had any chance to move it. The timing is the same + // on both sides of the mutation control below, which is what makes a 0 + // reading evidence rather than a race won. + return page.evaluate(() => new Promise<number>((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(document.querySelector<HTMLElement>('[data-conversation-scroll]')?.scrollLeft ?? -1) + }) + }) + })) +} + +/** A stop's readings plus where a horizontal wheel over it landed. */ +type ColumnStop = ColumnMetrics & { + /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */ + scrollLeftAfterWheel: number +} + +/** + * Render the golden body: one line per stop, relations only. + * + * Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`, + * which the fix pins to 0 by construction. The bleed is recorded as a boolean + * rather than its width, so the golden survives any platform whose column + * lands a pixel off — a fixture that has to be re-recorded per platform + * documents the platform, not the change. + * @param stops - the measured stops, in sweep order. + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(stops: ColumnStop[]): string { + return [ + '# Conversation column horizontal overflow', + '', + '| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |', + '| --- | --- | --- | --- | --- |', + ...stops.map(stop => `| ${String(stop.width)}px | ${stop.overflowX} | ${String(stop.glowBleeds)} ` + + `| ${String(stop.scrollLeftAfterWheel)}px | ${String(stop.scrollsVertically)} |`), + ].join('\n') +} + +describe('web e2e: the conversation column scrolls on one axis', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** + * Sweep the stops once and hand the readings to every assertion below, so + * the golden and the assertions describe the same measurement rather than + * two runs that could disagree. + * @returns the stops in {@link WIDTHS} order. + */ + const sweep = async (): Promise<ColumnStop[]> => { + const stops: ColumnStop[] = [] + for (const width of WIDTHS) { + await page.setViewportSize({ width, height: 900 }) + // The glow rides the hero box, which rides the column, and the column's + // track animates: settle on a column width that stops moving, or a stop + // gets read mid-transition and reports the previous viewport's relation. + let previous = -1 + await expect.poll(async () => { + const current = (await measureColumn(page, width)).columnWidth + const settled = current === previous + previous = current + return settled + }, { timeout: 10_000 }).toBe(true) + stops.push({ ...await measureColumn(page, width), scrollLeftAfterWheel: await wheelHorizontally(page) }) + } + return stops + } + + it('never scrolls horizontally, at any width the glow bleeds past', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow')) + const stops = await sweep() + // The vacuity guard, in two halves: the glow has to reach past the column + // at the narrow stops, and that reach has to still register as scrollable + // overflow. Without both, the claim below holds for free. + expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([1200, 1000, 800, 600]) + for (const stop of stops.filter(stop => stop.glowBleeds)) { + expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0) + } + for (const stop of stops) { + expect(stop.overflowX, `viewport ${String(stop.width)}`).toBe('hidden') + // The reported symptom, stated directly: a horizontal wheel over the + // column moves nothing, at every stop. + expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0) + // The axis the column is a scroller for must survive the fix. + expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true) + } + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('reports the pre-fix state when the axis is opened back up', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control')) + // The mutation control, run in the page rather than against a second + // build: it restores exactly what the fix changed — the initial `visible` + // that a one-axis scroller computes to `auto` — and shows the same gesture, + // at the same timing, carrying the column to the full bleed. Without it a + // `scrollLeft` of 0 could equally mean the wheel never arrived. + await page.setViewportSize({ width: 1200, height: 900 }) + // Injected with an id rather than through `addStyleTag`, so the teardown + // below can take the sheet out again by selector: it must not outlive this + // test, or the golden ends up reading the control. + await page.evaluate((id: string) => { + const sheet = document.createElement('style') + sheet.id = id + sheet.textContent = '[data-conversation-scroll] { overflow-x: auto !important; }' + document.head.append(sheet) + }, CONTROL_STYLE_ID) + try { + const before = await measureColumn(page, 1200) + expect(before.overflowX).toBe('auto') + expect(await wheelHorizontally(page)).toBe(before.bleedRange) + expect(before.bleedRange).toBeGreaterThan(0) + } finally { + await page.evaluate((id: string) => { + document.getElementById(id)?.remove() + }, CONTROL_STYLE_ID) + } + // The override is gone and the shipped state is back: the later goldens + // read the product, not the control. + expect((await measureColumn(page, 1200)).overflowX).toBe('hidden') + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('matches the committed column-overflow golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-golden')) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('commits exactly the fixtures it reads', async () => { + // No model calls, so no replay log: the golden is the whole inventory. + await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/conversation-column-overflow/geometry.expected.md b/apps/web/tests/snapshots/conversation-column-overflow/geometry.expected.md new file mode 100644 index 0000000000..f9c807b43e --- /dev/null +++ b/apps/web/tests/snapshots/conversation-column-overflow/geometry.expected.md @@ -0,0 +1,9 @@ +# Conversation column horizontal overflow + +| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically | +| --- | --- | --- | --- | --- | +| 1680px | hidden | false | 0px | true | +| 1200px | hidden | true | 0px | true | +| 1000px | hidden | true | 0px | true | +| 800px | hidden | true | 0px | true | +| 600px | hidden | true | 0px | true | diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 112731204b..c4e5869251 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -42,6 +42,7 @@ "tests/hmr-live.e2e.ts", "tests/seeded-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", + "tests/conversation-column-overflow.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index b1c5f51451..0be5a9fcc0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -191,6 +191,14 @@ flex-direction: column; min-height: 0; overflow-y: auto; + /* The column scrolls on ONE axis. Stating `hidden` rather than leaving the + initial `visible` is what removes the horizontal bar: a box that scrolls in + one axis computes `visible` to `auto` in the other, so any bleed becomes + user-scrollable. `.heroGlow` bleeds by construction (1051/776 of the hero + box), which put a horizontal scrollbar under every center column narrower + than the glow. Clipping is unchanged — `overflow-y: auto` already made this + a scroll container that clips both axes, so this only takes away the bar. */ + overflow-x: hidden; /* Reserved unconditionally: the composer seat rides this box's content box in Chat and its padding box under a view's composer overlay, so an `auto` gutter moves the input card sideways by the bar's width whenever the two diff --git a/tsconfig.host.json b/tsconfig.host.json index 77c06dc9fb..37fe3aad2b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,6 +29,7 @@ "apps/web/tests/hmr-live.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/sidebar-scrollbar.e2e.ts", + "apps/web/tests/conversation-column-overflow.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", From 21148232f52f1db82784acf5409b7838f1e57035 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Tue, 4 Aug 2026 15:08:36 +0800 Subject: [PATCH 070/190] test(web): give the horizontal-wheel reading a smooth-scroll settle The 0 the shipped column reports cannot be reached by polling for a settled value, so the read is a fixed wait; make that wait cover a smooth-scroll animation on any engine the lane runs on. Identical on both sides of the mutation control, which is what keeps the 0 evidence rather than a race won. --- apps/web/tests/conversation-column-overflow.e2e.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index e676a7df94..5744986598 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -125,12 +125,13 @@ async function wheelHorizontally(page: Page): Promise<number> { }) await page.mouse.move(origin.x, origin.y) await page.mouse.wheel(300, 0) - // Two frames: the scroll applies during the frame the wheel is dispatched - // into, and is readable in the next. Polling for a settled value cannot be + // A fixed settle, then two frames. Polling for a settled value cannot be // used here — the value under test is 0, which a poll starting at 0 accepts - // before the gesture has had any chance to move it. The timing is the same - // on both sides of the mutation control below, which is what makes a 0 - // reading evidence rather than a race won. + // before the gesture has had any chance to move it — so the wait is + // generous enough to cover a smooth-scroll animation on any engine the lane + // runs on. The timing is identical on both sides of the mutation control + // below, which is what makes a 0 reading evidence rather than a race won. + await page.waitForTimeout(400) return page.evaluate(() => new Promise<number>((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { From 94e2c8129df3c8a508f71077adc325193eedefd1 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Tue, 4 Aug 2026 15:14:06 +0800 Subject: [PATCH 071/190] test(web): share one sweep and settle the control's resize Review follow-ups on the column-overflow scenario: - Memoize the sweep so the golden and the assertions consume the same readings, which is what its contract already claimed; two runs could disagree if a resize settled differently between them. - Settle the column width before the mutation control measures. The test arrives from 1680 alone and from the sweep's 600 in a full run, and the frame eases its column tracks, so an immediate read can report the previous viewport's bleed. - Name the wheel delta, assert the bleed stays inside it, and compare the travelled distance rounded: a clamp or a sub-pixel would otherwise read as a broken fix. --- .../tests/conversation-column-overflow.e2e.ts | 76 +++++++++++++------ 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index 5744986598..03ba4f9572 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -49,6 +49,8 @@ const MODE = webSnapshotMode() const WIDTHS = [1680, 1200, 1000, 800, 600] /** Element id of the mutation control's injected sheet, so the test can take it back out. */ const CONTROL_STYLE_ID = 'dsh-column-overflow-control' +/** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */ +const WHEEL_DELTA = 300 /** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */ interface ColumnMetrics { @@ -124,7 +126,7 @@ async function wheelHorizontally(page: Page): Promise<number> { return { x: box.left + box.width / 2, y: box.top + 60 } }) await page.mouse.move(origin.x, origin.y) - await page.mouse.wheel(300, 0) + await page.mouse.wheel(WHEEL_DELTA, 0) // A fixed settle, then two frames. Polling for a settled value cannot be // used here — the value under test is 0, which a poll starting at 0 accepts // before the gesture has had any chance to move it — so the wait is @@ -190,28 +192,45 @@ describe('web e2e: the conversation column scrolls on one axis', () => { }) /** - * Sweep the stops once and hand the readings to every assertion below, so - * the golden and the assertions describe the same measurement rather than - * two runs that could disagree. + * Resize to a viewport and read the column once its width stops moving. + * + * The glow rides the hero box, which rides the column, and the frame eases + * its column tracks over `--ds-transition-duration-slow`: reading straight + * after a resize can report the previous viewport's relation, or a width + * caught mid-transition. + * @param width - viewport width to settle at. + * @returns the column's readings at that width. + */ + const settleAt = async (width: number): Promise<ColumnMetrics> => { + await page.setViewportSize({ width, height: 900 }) + let previous = -1 + await expect.poll(async () => { + const current = (await measureColumn(page, width)).columnWidth + const settled = current === previous + previous = current + return settled + }, { timeout: 10_000 }).toBe(true) + return measureColumn(page, width) + } + + /** + * Sweep the stops once per run and hand the SAME readings to every assertion + * below, so the golden and the assertions describe one measurement instead of + * two runs that could disagree. Memoized rather than re-run per test: the + * gestures below move the viewport, and a second sweep would be a second + * chance for a resize to settle differently. * @returns the stops in {@link WIDTHS} order. */ - const sweep = async (): Promise<ColumnStop[]> => { - const stops: ColumnStop[] = [] - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }) - // The glow rides the hero box, which rides the column, and the column's - // track animates: settle on a column width that stops moving, or a stop - // gets read mid-transition and reports the previous viewport's relation. - let previous = -1 - await expect.poll(async () => { - const current = (await measureColumn(page, width)).columnWidth - const settled = current === previous - previous = current - return settled - }, { timeout: 10_000 }).toBe(true) - stops.push({ ...await measureColumn(page, width), scrollLeftAfterWheel: await wheelHorizontally(page) }) - } - return stops + let swept: Promise<ColumnStop[]> | undefined + const sweep = (): Promise<ColumnStop[]> => { + swept ??= (async () => { + const stops: ColumnStop[] = [] + for (const width of WIDTHS) { + stops.push({ ...await settleAt(width), scrollLeftAfterWheel: await wheelHorizontally(page) }) + } + return stops + })() + return swept } it('never scrolls horizontally, at any width the glow bleeds past', async () => { @@ -242,7 +261,10 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // that a one-axis scroller computes to `auto` — and shows the same gesture, // at the same timing, carrying the column to the full bleed. Without it a // `scrollLeft` of 0 could equally mean the wheel never arrived. - await page.setViewportSize({ width: 1200, height: 900 }) + // Settle the resize first: this test runs at 1680 on its own and after the + // sweep's 600 in a full run, and an unsettled column reports the previous + // viewport's bleed. + await settleAt(1200) // Injected with an id rather than through `addStyleTag`, so the teardown // below can take the sheet out again by selector: it must not outlive this // test, or the golden ends up reading the control. @@ -255,8 +277,16 @@ describe('web e2e: the conversation column scrolls on one axis', () => { try { const before = await measureColumn(page, 1200) expect(before.overflowX).toBe('auto') - expect(await wheelHorizontally(page)).toBe(before.bleedRange) expect(before.bleedRange).toBeGreaterThan(0) + // The gesture has to be able to reach the far edge, or the equality below + // would fail on the clamp and read as a broken fix. Stated as its own + // assertion so that failure names itself. + expect(before.bleedRange).toBeLessThan(WHEEL_DELTA) + // Rounded: `scrollLeft` is fractional under a fractional layout while + // `scrollWidth - clientWidth` is integral, and the claim is that the + // column travelled the whole bleed — not that two engines agree on a + // sub-pixel. + expect(Math.round(await wheelHorizontally(page))).toBe(before.bleedRange) } finally { await page.evaluate((id: string) => { document.getElementById(id)?.remove() From 438b769fbc7f5f2ef127da85b7f2c474de76bb08 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Wed, 5 Aug 2026 18:38:58 +0800 Subject: [PATCH 072/190] test(web): reconcile one-axis scroll with stable gutter --- ...versation-column-one-axis-scroll.i18n.yaml | 4 +- ...-04-conversation-column-one-axis-scroll.md | 2 +- ...-conversation-column-one-axis-scroll.zh.md | 2 +- .../tests/conversation-column-overflow.e2e.ts | 44 ++++++++++++++----- .../geometry.expected.md | 6 +-- 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml index 47a432a7dd..754ca8bbd0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md -2026-08-04-conversation-column-one-axis-scroll.md: fa2347e5e1b8d41da020db69840e1dcf32cfc4c3 -2026-08-04-conversation-column-one-axis-scroll.zh.md: aba34e304b8e6a349bd1e295ceb00d5ad809f6dd +2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d +2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md index fa2347e5e1..9a487c506a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -30,7 +30,7 @@ Clipping does not change. `overflow-y: auto` had already made the box a scroll c [apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all. -Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to the full bleed — without it a `scrollLeft` of 0 could equally mean the wheel never arrived. +Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to its positive scroll boundary; the test measures that boundary directly because a stable scrollbar gutter can leave some overflow on the negative side of the scroll origin. Without the control, a `scrollLeft` of 0 could equally mean the wheel never arrived. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md index aba34e304b..23441a7c86 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -30,7 +30,7 @@ [apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上向列横向滚轮并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。 -两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到完整的外溢量——没有它,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。 +两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。 ## 后果 diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index 03ba4f9572..ae140bfd66 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -110,7 +110,7 @@ function measureColumn(page: Page, width: number): Promise<ColumnMetrics> { * programmatically scrollable and leaves `scrollWidth` untouched, so every * property reading agrees across the fix. Only refusing an actual input event * differs — measured at the 1200px stop, the shipped column stays at 0 while - * the same page with `overflow-x: auto` forced on lands at the full 66px bleed. + * the same page with `overflow-x: auto` forced on lands at its scroll boundary. * @param page - the page under test. * @returns `scrollLeft` after one horizontal wheel over the column. */ @@ -143,6 +143,28 @@ async function wheelHorizontally(page: Page): Promise<number> { })) } +/** + * Measure the positive horizontal scroll boundary without changing the + * shipped overflow mode. This is distinct from `scrollWidth - clientWidth` + * when a stable scrollbar gutter leaves part of the overflow on the negative + * side of the scroll origin. + * @param page - the page under test. + * @returns the greatest positive `scrollLeft` reachable by the control gesture. + */ +async function horizontalScrollLimit(page: Page): Promise<number> { + return page.evaluate((delta) => { + const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]') + if (scroller === null) throw new Error('conversation scroll container not in the DOM') + const previousScrollBehavior = scroller.style.scrollBehavior + scroller.style.scrollBehavior = 'auto' + scroller.scrollLeft = delta + const limit = scroller.scrollLeft + scroller.scrollLeft = 0 + scroller.style.scrollBehavior = previousScrollBehavior + return limit + }, WHEEL_DELTA) +} + /** A stop's readings plus where a horizontal wheel over it landed. */ type ColumnStop = ColumnMetrics & { /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */ @@ -259,8 +281,8 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // The mutation control, run in the page rather than against a second // build: it restores exactly what the fix changed — the initial `visible` // that a one-axis scroller computes to `auto` — and shows the same gesture, - // at the same timing, carrying the column to the full bleed. Without it a - // `scrollLeft` of 0 could equally mean the wheel never arrived. + // at the same timing, carrying the column to its positive scroll boundary. + // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived. // Settle the resize first: this test runs at 1680 on its own and after the // sweep's 600 in a full run, and an unsettled column reports the previous // viewport's bleed. @@ -278,15 +300,15 @@ describe('web e2e: the conversation column scrolls on one axis', () => { const before = await measureColumn(page, 1200) expect(before.overflowX).toBe('auto') expect(before.bleedRange).toBeGreaterThan(0) - // The gesture has to be able to reach the far edge, or the equality below - // would fail on the clamp and read as a broken fix. Stated as its own - // assertion so that failure names itself. - expect(before.bleedRange).toBeLessThan(WHEEL_DELTA) + const scrollLimit = await horizontalScrollLimit(page) + // The control has a reachable horizontal range, and the gesture exceeds + // it so the equality below proves that the wheel reached the far edge. + expect(scrollLimit).toBeGreaterThan(0) + expect(scrollLimit).toBeLessThan(WHEEL_DELTA) // Rounded: `scrollLeft` is fractional under a fractional layout while - // `scrollWidth - clientWidth` is integral, and the claim is that the - // column travelled the whole bleed — not that two engines agree on a - // sub-pixel. - expect(Math.round(await wheelHorizontally(page))).toBe(before.bleedRange) + // the claim is that the column reached the positive boundary, not that + // two engines agree on a sub-pixel. + expect(Math.round(await wheelHorizontally(page))).toBe(Math.round(scrollLimit)) } finally { await page.evaluate((id: string) => { document.getElementById(id)?.remove() diff --git a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md index b226cb845d..9735019508 100644 --- a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md +++ b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md @@ -2,7 +2,7 @@ ## Wide viewport (1680px, card at its cap) -- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat: scrollbar-gutter stable, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter stable, overflow hidden/auto @@ -14,7 +14,7 @@ ## Narrow viewport (800px, card shrinking with the column) -- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat: scrollbar-gutter stable, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter stable, overflow hidden/auto @@ -26,7 +26,7 @@ ## Wide viewport, reservation removed in the page (control) -- Chat: scrollbar-gutter auto, overflow auto/auto +- Chat: scrollbar-gutter auto, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter auto, overflow hidden/hidden From 9bba851a62c62a01167b0f02480fa3eb21200b7f Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 18:42:43 +0800 Subject: [PATCH 073/190] fix(tools): correct the trim-order claim and check the Literal escape dependency trim and escape commute for every input, so the new whitespace test does not pin their order: UNPRINTABLE and LONE_SURROGATE are disjoint from the set trim() strips, and both escapes emit plain non-whitespace ASCII, leaving the leading and trailing whitespace runs byte-identical. State that instead of the false causal clause. pyScalar's Literal path escapes nothing itself -- JSON.stringify is what keeps it parseable, covering NUL and, under ES2019 well-formed stringification, unpaired surrogates. Record the dependency and turn it into a checked invariant. Pin the docstring emission site for a lone surrogate too, mirroring the NUL case. Two docstring corrections: describe's caller enumeration omitted the synthetic { description } wrapper docLines builds, and "special in statement position" does not describe `_`, which is special in a match pattern. Both keep the conclusion they support. Note which of the two table guards fires depends on the entry point. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 22 ++++++++++++++----- packages/core/tools/tests/py-types.spec.ts | 14 +++++++++++- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 5cafc77562..0322704391 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: cbcc8eb54ce78b922e584d050bb9d6a73439a08c -2026-07-31-code-mode-language-dispatch.zh.md: 5502daf926a62fa2b6981457be8f2b5583f477b8 +2026-07-31-code-mode-language-dispatch.md: d891ef171344d729ae93f98f6662608f432e5b78 +2026-07-31-code-mode-language-dispatch.zh.md: fd1c00f754b0e6c21cac659ac303482ec60e156a diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index cbcc8eb54c..d891ef1713 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -37,7 +37,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe ## Consequences -Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. +Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. Which of the two failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 5502daf926..fd1c00f754 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -37,7 +37,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 +新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。对两张表都缺席的语言,报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index a74729d9ff..90cae053a4 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -27,9 +27,11 @@ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ * class-syntax `TypedDict` field. Such a tool renders under subscript access * and such an object degrades to ``dict[str, Any]`` — the model still reaches * every tool and field without collisions. - * Soft keywords (``match``, ``case``, ``type``, ``_``) are deliberately - * ABSENT: they are only special in statement position, so ``match: str`` as a - * field and ``async def match(...)`` as a method are both legal, and including + * Soft keywords (``match``, ``case``, ``type``, ``_`` — the language + * reference's whole set) are deliberately ABSENT: each is special in exactly + * one syntactic position — a statement head, or a ``match`` pattern for ``_`` + * — so ``match: str`` as a field and ``async def match(...)`` as a method are + * both legal, and including * them would needlessly degrade common search/regex tool fields to * ``dict[str, Any]``. Underscore-leading names are handled separately, not * here: a non-dunder ``__token`` name-mangles, a dunder present on @@ -113,8 +115,9 @@ const LONE_SURROGATE = /[\ud800-\udfff]/gu /** * The collapsed one-line `description` of a schema node (byte-stable across * formatting churn), or `undefined` when the node carries none. Every caller - * passes an object (validated property nodes, or the ToolSdkSchema itself), - * so only the description field needs guarding. A description that collapses + * passes an object — a validated property node, the `ToolSdkSchema` itself, or + * the `{ description }` wrapper {@link docLines} synthesizes — so only the + * description field needs guarding. A description that collapses * to nothing (empty, or whitespace only) is `undefined` too: it documents the * node no better than an absent one, and emitting it would leave an empty * `"""` docstring or a bare `# ` line in the SDK. Only ECMAScript whitespace @@ -257,6 +260,15 @@ function childClassName(base: string, segment: string): string { * representable as a JavaScript number, so the SDK would document a value no * program can pass. The TS flavor needs no counterpart: its literal is re-read * by a JS parser back into the same double. + * + * `JSON.stringify` is also what keeps this path's output parseable, and it is + * the only thing that does: it escapes both code points CPython refuses in + * source — NUL among the C0 controls, and unpaired surrogates under ES2019 + * well-formed stringification, which the engines range guarantees. The + * `description` path carries {@link UNPRINTABLE} and {@link LONE_SURROGATE} + * because nothing quotes it. DEL and the C1 controls do reach a `Literal[...]` + * raw — legal but invisible, byte-for-byte as in the TS flavor; escaping them + * is a both-flavors change. */ function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 61379d22ce..8adf364171 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -51,6 +51,15 @@ describe('jsonSchemaToPy', () => { expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any') }) + it('leans on JSON.stringify to keep a Literal parseable', () => { + // The two code points CPython refuses in source reach this path as well, + // and nothing here escapes them itself — `JSON.stringify` does, NUL as a + // C0 control and a lone surrogate under ES2019 well-formed stringification. + // Python decodes both escapes back to the value the schema declared. + expect(jsonSchemaToPy({ type: 'string', const: 'a\u0000b' })).toBe(String.raw`Literal["a\u0000b"]`) + expect(jsonSchemaToPy({ type: 'string', enum: ['a\ud800b'] })).toBe(String.raw`Literal["a\ud800b"]`) + }) + it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded @@ -750,7 +759,9 @@ describe('renderToolsSdkPy', () => { // so the block stays parseable with the code point intact. expect(renderToolsSdkPy([described('zero\u200bwidth')])).toContain('"""zero\u200bwidth"""') // Whitespace around a surviving control character is not an absent - // description: the escape runs before the trim, so what is left is visible. + // description. The escape's output is non-whitespace ASCII and the escaped + // sets are disjoint from what `trim()` strips, so the two operations touch + // different characters and their order is unobservable. expect(renderToolsSdkPy([described(' \u0085 ')])).toContain(String.raw`# \x85`) }) @@ -764,6 +775,7 @@ describe('renderToolsSdkPy', () => { const high = renderToolsSdkPy([described('a\ud800b')]) expect(high).not.toContain('\ud800') expect(high).toContain(String.raw`# a\ud800b`) + expect(high).toContain(String.raw`"""a\\ud800b"""`) // A lone LOW surrogate is just as unencodable, and `\xNN` reaches neither. expect(renderToolsSdkPy([described('a\udfffb')])).toContain(String.raw`# a\udfffb`) // A well-formed pair is ONE astral code point, not two surrogates — the From dbefb2fa9004b76b49c4668477893325b7b409be Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 19:01:18 +0800 Subject: [PATCH 074/190] fix(tools): complete the Literal parseability attribution and the soft-keyword positions pyScalar's docstring named only the two code points CPython refuses anywhere in source. A bare quote, a trailing odd backslash, and a bare LF/CR break the Literal line just as fatally, and JSON.stringify is what covers those too. The argument also leaned on an unstated coincidence: every escape JSON.stringify can emit is a Python escape for the same character, which is why the emitted text both parses and decodes back to the declared value. Say both, and assert the second class. "statement head" does not describe `case`, whose clause block is not a statement. Split the positions three ways. Add the mode 'both' by python assembly, pinning the mode-by-language matrix rather than leaving it to the shared code path. --- packages/core/tools/src/py-types.ts | 27 ++++++++++++++------- packages/core/tools/tests/code-mode.spec.ts | 15 ++++++++++++ packages/core/tools/tests/py-types.spec.ts | 14 ++++++++--- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 90cae053a4..c879e04a72 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -29,9 +29,10 @@ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ * every tool and field without collisions. * Soft keywords (``match``, ``case``, ``type``, ``_`` — the language * reference's whole set) are deliberately ABSENT: each is special in exactly - * one syntactic position — a statement head, or a ``match`` pattern for ``_`` - * — so ``match: str`` as a field and ``async def match(...)`` as a method are - * both legal, and including + * one syntactic position — a statement head (``match``, ``type``), a ``match`` + * statement's clause head (``case``), or a pattern (``_``) — so ``match: str`` + * as a field and ``async def match(...)`` as a method are both legal, and + * including * them would needlessly degrade common search/regex tool fields to * ``dict[str, Any]``. Underscore-leading names are handled separately, not * here: a non-dunder ``__token`` name-mangles, a dunder present on @@ -262,13 +263,21 @@ function childClassName(base: string, segment: string): string { * by a JS parser back into the same double. * * `JSON.stringify` is also what keeps this path's output parseable, and it is - * the only thing that does: it escapes both code points CPython refuses in - * source — NUL among the C0 controls, and unpaired surrogates under ES2019 - * well-formed stringification, which the engines range guarantees. The + * the only thing that does. It covers both classes of hazard: the two code + * points CPython refuses anywhere in source — NUL among the C0 controls, and + * unpaired surrogates under ES2019 well-formed stringification, which the + * engines range guarantees — and the ones that break this line in particular, + * a bare `"` closing the literal early, a trailing odd backslash eating the + * closing quote, and a bare LF/CR ending it before its terminator. The * `description` path carries {@link UNPRINTABLE} and {@link LONE_SURROGATE} - * because nothing quotes it. DEL and the C1 controls do reach a `Literal[...]` - * raw — legal but invisible, byte-for-byte as in the TS flavor; escaping them - * is a both-flavors change. + * because nothing quotes it, and folds newlines in {@link describe}. + * + * That leans on a coincidence worth naming: every escape `JSON.stringify` can + * emit (`\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) is also a Python + * escape denoting the same character, so the emitted `Literal[...]` both + * parses and decodes back to the value the schema declared. DEL and the C1 + * controls do reach it raw — legal but invisible, byte-for-byte as in the TS + * flavor; escaping them is a both-flavors change. */ function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 933881fd50..1fa6e5064d 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -350,6 +350,21 @@ describe('mode-aware wire contribution', () => { expect(sdk?.text).toContain('top-level `await`') }) + it("assembles under a python runtime in mode 'both' as well, SDK and schema together", async () => { + // `both` reaches the same wireSchemas/requireCodeRuntime/SDK-section code + // as `code`, so this pins the mode-by-language matrix rather than a + // separate path — including that `schemas()` under `both` projects the + // Python flavor instead of hitting the flavor-table guard. + const { ctx, systemPrompt } = await setup({ mode: 'both', runtime: { language: 'python' } }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('class Tools(Protocol):') + const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME) + expect(runCodeSchema?.description).toContain('Execute a Python program') + // `both` keeps the native tools alongside run_code; `code` does not. + expect(assembly.tools.map(tool => tool.name)).toContain('echo') + }) + it('emits a TypeScript-flavored run_code schema under a typescript runtime', async () => { const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'typescript' } }) registerEcho(ctx) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 8adf364171..35d5449bfe 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -52,12 +52,18 @@ describe('jsonSchemaToPy', () => { }) it('leans on JSON.stringify to keep a Literal parseable', () => { - // The two code points CPython refuses in source reach this path as well, - // and nothing here escapes them itself — `JSON.stringify` does, NUL as a - // C0 control and a lone surrogate under ES2019 well-formed stringification. - // Python decodes both escapes back to the value the schema declared. + // Nothing here escapes anything itself; `JSON.stringify` carries both + // classes of hazard. The two code points CPython refuses anywhere in + // source: NUL, and a lone surrogate under ES2019 well-formed + // stringification. expect(jsonSchemaToPy({ type: 'string', const: 'a\u0000b' })).toBe(String.raw`Literal["a\u0000b"]`) expect(jsonSchemaToPy({ type: 'string', enum: ['a\ud800b'] })).toBe(String.raw`Literal["a\ud800b"]`) + // And the ones that break this line in particular: a bare quote closing + // the literal early, a trailing backslash eating the closing quote, a bare + // newline ending it before its terminator. Every escape it emits is also a + // Python escape for the same character, so the value round-trips. + expect(jsonSchemaToPy({ type: 'string', const: 'say "hi"\n' })).toBe(String.raw`Literal["say \"hi\"\n"]`) + expect(jsonSchemaToPy({ type: 'string', const: 'ends\\' })).toBe(String.raw`Literal["ends\\"]`) }) it('emits exact digits for a beyond-safe-range integer literal', () => { From f3c8695fd61ae949c90f0a3ee4c6d454493782ec Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 19:07:10 +0800 Subject: [PATCH 075/190] test(tools): pin the argument-annotation nesting cap, the worst of the three sites The 182 the cap is chosen against had no direct case: the existing tests cover the root chain and the TypedDict field, both of which start one bracket lower. An array-rooted parameters schema reaches it from a plain ToolSdkSchema literal, no raw register() needed. Exactly 180 arrays over a const scalar is the worst case itself -- the root frame starts at listDepth 0, so every list[ still emits and the innermost Literal[ is reached rather than degraded; one deeper is where the item degrades. Name the subscript tool-name comment in pyScalar's docstring: it quotes through the same JSON.stringify call and inherits the same escapes and the same pass-throughs. --- packages/core/tools/src/py-types.ts | 4 +++- packages/core/tools/tests/py-types.spec.ts | 26 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index c879e04a72..243a1ce13f 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -277,7 +277,9 @@ function childClassName(base: string, segment: string): string { * escape denoting the same character, so the emitted `Literal[...]` both * parses and decodes back to the value the schema declared. DEL and the C1 * controls do reach it raw — legal but invisible, byte-for-byte as in the TS - * flavor; escaping them is a both-flavors change. + * flavor; escaping them is a both-flavors change. The subscript tool-name + * comment quotes its name through the same call and inherits both halves, + * escapes and pass-throughs alike. */ function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 35d5449bfe..b63edffd2b 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -571,6 +571,32 @@ describe('renderToolsSdkPy', () => { expect(renderToolsSdkPy([tool])).toContain(` rows: ${'list['.repeat(179)}str${']'.repeat(179)}`) }) + it('caps the argument annotation, the site whose enclosing paren stays open', () => { + // The worst of the three emission sites: the parameter list's `(` is still + // open around this annotation, so 180 `list[` plus the innermost bracket + // plus that paren is 182 of CPython's 200. Only a raw `register()` reaches + // it — `defineTool` compiles an object root, whose annotation is a bare + // TypedDict name that opens nothing. + const rooted = (depth: number): ToolSdkSchema => { + let schema: Record<string, unknown> = { type: 'string', const: 'x' } + for (let i = 0; i < depth; i++) schema = { type: 'array', items: schema } + return { name: 'rooted', description: 'Array-rooted parameters.', parameters: schema, output: { type: 'string' } } + } + // Exactly at the cap with a scalar underneath is the worst case itself: the + // chain's root frame starts at `listDepth: 0` here, so all 180 `list[` + // still emit and the innermost `Literal[` is reached rather than degraded. + const worst = renderToolsSdkPy([rooted(180)]) + expect(worst).toContain(`async def rooted(self, args: ${'list['.repeat(180)}Literal["x"]${']'.repeat(180)}) -> str:`) + const annotation = worst.split('async def rooted(self, args: ')[1]!.split(') -> str:')[0]! + // 181 brackets on the annotation plus the still-open parameter-list paren, + // the 182 the cap is chosen against. + expect(annotation.split('[').length - 1).toBe(181) + // One array deeper is where the degradation lands, and it lands on the item + // rather than on another `list[`, so the count cannot grow past that. + expect(renderToolsSdkPy([rooted(181)])) + .toContain(`async def rooted(self, args: ${'list['.repeat(180)}Any${']'.repeat(180)}) -> str:`) + }) + it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => { // Each level is a two-branch oneOf whose first branch recurses; joining the // accumulated union string at every level would be Theta(depth^2). At this From cfc2783b878b817326c88700f81683ada73556a7 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Wed, 5 Aug 2026 19:09:26 +0800 Subject: [PATCH 076/190] test(web): stabilize the overflow mutation control --- .../tests/conversation-column-overflow.e2e.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index ae140bfd66..97e1fd1fa2 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -40,13 +40,13 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-over */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() +/** Narrow sweep stop where the mutation control retains overflow across scrollbar implementations. */ +const CONTROL_VIEWPORT = 600 /** - * Viewport widths bracketing the glow. The hero box is `min(776, column - 48)` - * and the glow is 1051/776 of it, so every stop under a ~1051px column bleeds - * and the widest one does not — the sweep therefore covers both sides of the - * relation rather than sampling one comfortable width. + * Viewport widths bracketing the glow: the narrow stops retain the reported + * bleed while the widest stop proves the relation can also be false. */ -const WIDTHS = [1680, 1200, 1000, 800, 600] +const WIDTHS = [1680, 1200, 1000, 800, CONTROL_VIEWPORT] /** Element id of the mutation control's injected sheet, so the test can take it back out. */ const CONTROL_STYLE_ID = 'dsh-column-overflow-control' /** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */ @@ -261,7 +261,9 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // The vacuity guard, in two halves: the glow has to reach past the column // at the narrow stops, and that reach has to still register as scrollable // overflow. Without both, the claim below holds for free. - expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([1200, 1000, 800, 600]) + expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([ + 1200, 1000, 800, CONTROL_VIEWPORT, + ]) for (const stop of stops.filter(stop => stop.glowBleeds)) { expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0) } @@ -283,10 +285,6 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // that a one-axis scroller computes to `auto` — and shows the same gesture, // at the same timing, carrying the column to its positive scroll boundary. // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived. - // Settle the resize first: this test runs at 1680 on its own and after the - // sweep's 600 in a full run, and an unsettled column reports the previous - // viewport's bleed. - await settleAt(1200) // Injected with an id rather than through `addStyleTag`, so the teardown // below can take the sheet out again by selector: it must not outlive this // test, or the golden ends up reading the control. @@ -297,7 +295,10 @@ describe('web e2e: the conversation column scrolls on one axis', () => { document.head.append(sheet) }, CONTROL_STYLE_ID) try { - const before = await measureColumn(page, 1200) + // Resolve the mutated layout at the narrowest sweep stop. At wider stops, + // a classic scrollbar can change the available box enough to remove the + // overflow that the control is meant to expose. + const before = await settleAt(CONTROL_VIEWPORT) expect(before.overflowX).toBe('auto') expect(before.bleedRange).toBeGreaterThan(0) const scrollLimit = await horizontalScrollLimit(page) @@ -316,7 +317,7 @@ describe('web e2e: the conversation column scrolls on one axis', () => { } // The override is gone and the shipped state is back: the later goldens // read the product, not the control. - expect((await measureColumn(page, 1200)).overflowX).toBe('hidden') + expect((await settleAt(CONTROL_VIEWPORT)).overflowX).toBe('hidden') expect(tripwire.pageErrors).toEqual([]) }, 120_000) From 72991bbcdb78eef6986de131ac811ae857d03ca5 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 19:17:34 +0800 Subject: [PATCH 077/190] fix(tools): count kinds of code point, not code points, and cover a hostile tool name "the two code points CPython refuses" counted classes: NUL is one code point, unpaired surrogates are the whole 2,048-wide D800-DFFF block. Say kinds, in both the docstring and the test comment that mirrors it, and restore the "odd" qualifier the test comment dropped -- an even trailing backslash run does not eat the closing quote. The soft-keyword test title still said "only special in statement position", which the previous commit's own three-way split contradicts for `case`: `case_block` is a clause head inside a `match` statement, not a statement. Add the case the subscript tool-name path lacked. A lone surrogate is reachable in a name through JSON.parse of MCP wire JSON, and that path has no UNPRINTABLE / LONE_SURROGATE fallback -- only the same ES2019 well-formed stringification the Literal path leans on. --- packages/core/tools/src/py-types.ts | 9 +++--- packages/core/tools/tests/py-types.spec.ts | 33 +++++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 243a1ce13f..a91816fadd 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -263,10 +263,11 @@ function childClassName(base: string, segment: string): string { * by a JS parser back into the same double. * * `JSON.stringify` is also what keeps this path's output parseable, and it is - * the only thing that does. It covers both classes of hazard: the two code - * points CPython refuses anywhere in source — NUL among the C0 controls, and - * unpaired surrogates under ES2019 well-formed stringification, which the - * engines range guarantees — and the ones that break this line in particular, + * the only thing that does. It covers both classes of hazard: the two kinds of + * code point CPython refuses anywhere in source — NUL among the C0 controls, + * and the whole D800–DFFF unpaired-surrogate block, escaped under ES2019 + * well-formed stringification, which the engines range guarantees — and the + * ones that break this line in particular, * a bare `"` closing the literal early, a trailing odd backslash eating the * closing quote, and a bare LF/CR ending it before its terminator. The * `description` path carries {@link UNPRINTABLE} and {@link LONE_SURROGATE} diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index b63edffd2b..5b61523405 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -53,15 +53,16 @@ describe('jsonSchemaToPy', () => { it('leans on JSON.stringify to keep a Literal parseable', () => { // Nothing here escapes anything itself; `JSON.stringify` carries both - // classes of hazard. The two code points CPython refuses anywhere in - // source: NUL, and a lone surrogate under ES2019 well-formed - // stringification. + // classes of hazard. The two kinds of code point CPython refuses anywhere + // in source: NUL, and the D800–DFFF unpaired-surrogate block under ES2019 + // well-formed stringification. expect(jsonSchemaToPy({ type: 'string', const: 'a\u0000b' })).toBe(String.raw`Literal["a\u0000b"]`) expect(jsonSchemaToPy({ type: 'string', enum: ['a\ud800b'] })).toBe(String.raw`Literal["a\ud800b"]`) // And the ones that break this line in particular: a bare quote closing - // the literal early, a trailing backslash eating the closing quote, a bare - // newline ending it before its terminator. Every escape it emits is also a - // Python escape for the same character, so the value round-trips. + // the literal early, a trailing ODD backslash eating the closing quote (an + // even run does not), a bare newline ending it before its terminator. + // Every escape it emits is also a Python escape for the same character, so + // the value round-trips. expect(jsonSchemaToPy({ type: 'string', const: 'say "hi"\n' })).toBe(String.raw`Literal["say \"hi\"\n"]`) expect(jsonSchemaToPy({ type: 'string', const: 'ends\\' })).toBe(String.raw`Literal["ends\\"]`) }) @@ -368,7 +369,7 @@ describe('renderToolsSdkPy', () => { expect(text).not.toContain('WeirdFieldsArgs') }) - it('keeps soft-keyword field names as TypedDict fields (match/case/type are only special in statement position)', () => { + it('keeps soft-keyword field names as TypedDict fields (each is special in exactly one syntactic position)', () => { const tool: ToolSdkSchema = { name: 'search', description: 'Soft keywords as fields.', @@ -740,6 +741,24 @@ describe('renderToolsSdkPy', () => { expect(text).toContain(' pass\n') }) + it('quotes a tool name through the same JSON.stringify the Literal path depends on', () => { + // A lone surrogate is reachable in a name — `"\ud800"` survives + // `JSON.parse` of MCP wire JSON — and this path has no UNPRINTABLE / + // LONE_SURROGATE fallback behind it, only ES2019 well-formed + // stringification. Raw, it would make the whole SDK block uncompilable, + // exactly as on the `Literal[...]` path. + const text = renderToolsSdkPy([ + { + name: 'a\ud800b', + description: 'Lone surrogate in the name.', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>, + output: { type: 'string' }, + }, + ]) + expect(text).toContain(String.raw`# tools["a\ud800b"](args: dict[str, Any]) -> str`) + expect(text).not.toContain('\ud800') + }) + it('escapes quotes and backslashes in descriptions so the docstring stays valid Python', () => { // A description ending in `"` or an odd backslash would otherwise merge // with (or escape) the closing triple quote — and this block is Code From b44acab888eee56da42196af0448cff334529d99 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 19:22:42 +0800 Subject: [PATCH 078/190] docs(tools): correct three comment claims about what defineTool and the subscript path do "defineTool compiles an object root, so the annotation is a bare TypedDict class name that opens nothing" is a false universal: parameterSchemaSpecToJsonSchema compiles an OPEN object root, so an empty parameter table and one with unrepresentable field names both degrade to dict[str, Any], which opens one bracket. The conclusion the sentence carries is unaffected -- 1 or 2 against a 182 cap -- so say "a bare TypedDict class name or dict[str, Any], neither of which carries a chain", in the JSDoc and the test comment that copied it. pyScalar's docstring said the subscript tool-name comment quotes "through the same call". It quotes through its own JSON.stringify call site in renderToolsSdkPy and never reaches pyScalar, which only takes const/enum scalars. Same function, different call site. The mode-'both' test attributed assembly.tools to the public schemas(). That projection is wireSchemas, wired at ctx.systemPrompt.tools. --- packages/core/tools/src/py-types.ts | 8 +++++--- packages/core/tools/tests/code-mode.spec.ts | 5 +++-- packages/core/tools/tests/py-types.spec.ts | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index a91816fadd..807c9de79c 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -192,7 +192,8 @@ const MAX_CLASS_NAME_BASE = 120 * still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the * worst case. Reachable only through a raw `register()` whose `parameters` * is array-rooted; `defineTool` compiles an object root, so the annotation - * is a bare TypedDict class name that opens nothing. + * is a bare TypedDict class name or `dict[str, Any]` — neither carries a + * chain. * * A CPython grammar limit, not a deployment choice, so it is fixed rather than * configurable. The sibling `ts-types` renderer needs no counterpart: nothing @@ -279,8 +280,9 @@ function childClassName(base: string, segment: string): string { * parses and decodes back to the value the schema declared. DEL and the C1 * controls do reach it raw — legal but invisible, byte-for-byte as in the TS * flavor; escaping them is a both-flavors change. The subscript tool-name - * comment quotes its name through the same call and inherits both halves, - * escapes and pass-throughs alike. + * comment quotes its name through its own call to the same `JSON.stringify`, + * never through this function, and inherits both halves — escapes and + * pass-throughs alike. */ function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 1fa6e5064d..ee3ef2a91a 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -353,8 +353,9 @@ describe('mode-aware wire contribution', () => { it("assembles under a python runtime in mode 'both' as well, SDK and schema together", async () => { // `both` reaches the same wireSchemas/requireCodeRuntime/SDK-section code // as `code`, so this pins the mode-by-language matrix rather than a - // separate path — including that `schemas()` under `both` projects the - // Python flavor instead of hitting the flavor-table guard. + // separate path — including that the `wireSchemas` projection behind + // `assembly.tools` picks the Python flavor under `both` instead of hitting + // the flavor-table guard. const { ctx, systemPrompt } = await setup({ mode: 'both', runtime: { language: 'python' } }) registerEcho(ctx) const assembly = await systemPrompt.assemble() diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 5b61523405..5b0873aa25 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -577,7 +577,7 @@ describe('renderToolsSdkPy', () => { // open around this annotation, so 180 `list[` plus the innermost bracket // plus that paren is 182 of CPython's 200. Only a raw `register()` reaches // it — `defineTool` compiles an object root, whose annotation is a bare - // TypedDict name that opens nothing. + // TypedDict name or `dict[str, Any]`, neither of which carries a chain. const rooted = (depth: number): ToolSdkSchema => { let schema: Record<string, unknown> = { type: 'string', const: 'x' } for (let i = 0; i < depth; i++) schema = { type: 'array', items: schema } From 015bef2f5f2fd9abe89747acea12500727805141 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 19:37:11 +0800 Subject: [PATCH 079/190] docs(tools): widen the 182 reachability shape and finish the note's two language-binding facts "Reachable only through a raw register() whose parameters is array-rooted" was too narrow. A root oneOf reaches the same 182: the union arm propagates listDepth unchanged because `A | B` opens no bracket, so an array branch starts its chain at 0 exactly as an array root does. Say "root opens an array chain -- rooted at the array, or at an array branch of a root oneOf", in the JSDoc and the test comment, and assert the union shape alongside the array-rooted one. The note's Decision paragraph said the flavor guard is reached under "a language that has a renderer but no flavor entry, and a test covers it". The test uses ruby, absent from both tables, and the mechanism is that schemas() reaches run_code's getters without passing requireCodeRuntime -- so any language absent from the flavor table hits it. State that instead. The Consequences paragraph recorded the language-binding obligation as two reads, assembly and execution. Within one projection there are more: run_code's description and parameters getters each call resolveFlavor(peekRuntime()) and schemaOf destructures both, so a reload between them yields one schema whose halves name different languages. --- ...26-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 4 ++-- .../2026-07-31-code-mode-language-dispatch.zh.md | 4 ++-- packages/core/tools/src/py-types.ts | 8 +++++--- packages/core/tools/tests/py-types.spec.ts | 15 ++++++++++++--- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 0322704391..aace1e2702 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: d891ef171344d729ae93f98f6662608f432e5b78 -2026-07-31-code-mode-language-dispatch.zh.md: fd1c00f754b0e6c21cac659ac303482ec60e156a +2026-07-31-code-mode-language-dispatch.md: 3b78783744e2e30cf34c0603332c050252bda447 +2026-07-31-code-mode-language-dispatch.zh.md: 17fb63d686ae695b564e9283c413f8e589d56810 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index d891ef1713..3b78783744 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -17,7 +17,7 @@ Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at - `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. - `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. -Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — reading `ctx.tools.schemas()` under a runtime whose language has a renderer but no flavor entry hits it, and a test covers it. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is two table entries plus its renderer — no `agent-loop` or registry-structure change. +Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which `schemas()` reaches without passing `requireCodeRuntime` first, and a test covers it. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is two table entries plus its renderer — no `agent-loop` or registry-structure change. `code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master. @@ -41,4 +41,4 @@ Adding a backend language is two table entries — an `SDK_RENDERERS` entry and The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. -Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. +Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both per definition, so one projection reads the runtime twice per tool; a reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index fd1c00f754..17fb63d686 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——在语言有渲染器却无 flavor 表项的运行时下读 `ctx.tools.schemas()` 即到达,且有测试覆盖。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`,且有测试覆盖。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 @@ -41,4 +41,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 -Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 +Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 对每个 definition 解构这两个字段,因此一次投影对每个工具读两次运行时;在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 807c9de79c..ccbffa02ad 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -191,9 +191,11 @@ const MAX_CLASS_NAME_BASE = 120 * - Argument annotation, `async def f(self, args: chain) -> Y:` — the `(` IS * still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the * worst case. Reachable only through a raw `register()` whose `parameters` - * is array-rooted; `defineTool` compiles an object root, so the annotation - * is a bare TypedDict class name or `dict[str, Any]` — neither carries a - * chain. + * root opens an array chain — rooted at the array, or at an array branch of + * a root `oneOf`, which inherits the enclosing depth because a union adds no + * brackets. `defineTool` compiles an object root, so the annotation is a + * bare TypedDict class name or a one-bracket `dict[str, Any]` when that + * object degrades — never a chain. * * A CPython grammar limit, not a deployment choice, so it is fixed rather than * configurable. The sibling `ts-types` renderer needs no counterpart: nothing diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 5b0873aa25..cafcaa1530 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -575,9 +575,11 @@ describe('renderToolsSdkPy', () => { it('caps the argument annotation, the site whose enclosing paren stays open', () => { // The worst of the three emission sites: the parameter list's `(` is still // open around this annotation, so 180 `list[` plus the innermost bracket - // plus that paren is 182 of CPython's 200. Only a raw `register()` reaches - // it — `defineTool` compiles an object root, whose annotation is a bare - // TypedDict name or `dict[str, Any]`, neither of which carries a chain. + // plus that paren is 182 of CPython's 200. Only a raw `register()` whose + // `parameters` root opens an array chain reaches it — rooted at the array, + // or at an array branch of a root `oneOf`, since a union adds no brackets. + // `defineTool` compiles an object root, whose annotation is a bare + // TypedDict name or a one-bracket `dict[str, Any]`, never a chain. const rooted = (depth: number): ToolSdkSchema => { let schema: Record<string, unknown> = { type: 'string', const: 'x' } for (let i = 0; i < depth; i++) schema = { type: 'array', items: schema } @@ -596,6 +598,13 @@ describe('renderToolsSdkPy', () => { // rather than on another `list[`, so the count cannot grow past that. expect(renderToolsSdkPy([rooted(181)])) .toContain(`async def rooted(self, args: ${'list['.repeat(180)}Any${']'.repeat(180)}) -> str:`) + // A root union reaches the same 182: its branches inherit the enclosing + // depth because `A | B` opens nothing, so the chain under one of them + // starts at 0 exactly as the array-rooted case does. + const union = { ...rooted(180), parameters: { oneOf: [rooted(180).parameters, { type: 'string' }] } } + const text = renderToolsSdkPy([union]) + expect(text).toContain(`args: ${'list['.repeat(180)}Literal["x"]${']'.repeat(180)} | str) -> str:`) + expect(text.split('async def rooted(self, args: ')[1]!.split(') -> str:')[0]!.split('[').length - 1).toBe(181) }) it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => { From ba634896e00f7876d427fe4094eb932bab9ffe5d Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 19:44:05 +0800 Subject: [PATCH 080/190] docs(tools): name the boundary that rejects a padded integer, and what the flavor-guard test actually reads pyScalar's docstring attributed the rejection of a String-spelled beyond-safe-range integer to "the Python runtime". No published backend makes that call on this base. The fact that does not depend on one: the padded digits name an integer no double holds, and passing it back would have to cross the argument boundary as a JSON number. Say that, and say why String rounds at all -- Number::toString is shortest round-trip, so 2 ** 60 emits the 16 digits that re-read to the same double and pads. Mirror both in the test comment. The note's Decision sentence said a test covers the flavor guard through ctx.tools.schemas(). The test reads the definition's getter directly, under a language absent from both tables; schemas() reaches the same getter but has no assertion. Name what is read, and record that a renderer-without-flavor language is drift this guards against rather than an existing input -- the two key sets are identical today. --- ...2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 2 +- .../2026-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 13 ++++++++----- packages/core/tools/tests/py-types.spec.ts | 10 +++++++--- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index aace1e2702..e95ce168ca 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 3b78783744e2e30cf34c0603332c050252bda447 -2026-07-31-code-mode-language-dispatch.zh.md: 17fb63d686ae695b564e9283c413f8e589d56810 +2026-07-31-code-mode-language-dispatch.md: c2010ec368da82d8c41df8d00a8e32f0064afde3 +2026-07-31-code-mode-language-dispatch.zh.md: 3cc3bae8c683e8434f48dd251b9dd5dd580bc3ce diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 3b78783744..c2010ec368 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -17,7 +17,7 @@ Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at - `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. - `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. -Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which `schemas()` reaches without passing `requireCodeRuntime` first, and a test covers it. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is two table entries plus its renderer — no `agent-loop` or registry-structure change. +Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is the drift this guards against, not an input that exists — the two tables' key sets are identical today. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is two table entries plus its renderer — no `agent-loop` or registry-structure change. `code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 17fb63d686..3cc3bae8c6 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`,且有测试覆盖。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」是这个守卫所防的表漂移,不是已存在的输入——两张表当前键集相同。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index ccbffa02ad..5021995b09 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -259,11 +259,14 @@ function childClassName(base: string, segment: string): string { * `String`: Python integers are arbitrary-precision, so the emitted digits ARE * the value the model programs against, and `String` gives a different integer * than the double holds (`2 ** 60` prints the rounded `...847000`, not the - * exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`). The - * Python runtime then rejects the advertised literal as not exactly - * representable as a JavaScript number, so the SDK would document a value no - * program can pass. The TS flavor needs no counterpart: its literal is re-read - * by a JS parser back into the same double. + * exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`). + * `String`'s rounding is not a bug in it: `Number::toString` is shortest + * round-trip, so it emits the 16 digits that re-read to the same double and + * pads with zeros, and those padded digits name an integer no double holds. + * Passing one back would have to cross the argument boundary as a JSON number + * — a double again — so the SDK would document a value no program can pass. + * The TS flavor needs no counterpart: its literal is re-read by a JS parser + * back into the same double. * * `JSON.stringify` is also what keeps this path's output parseable, and it is * the only thing that does. It covers both classes of hazard: the two kinds of diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index cafcaa1530..60291aa026 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -71,9 +71,13 @@ describe('jsonSchemaToPy', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded // ...847000, which is a DIFFERENT integer from the double's exact - // ...846976 — the Python runtime would reject the advertised literal as - // not exactly representable as a JavaScript number, so the SDK would - // document a value no program can pass. + // ...846976: `Number::toString` is shortest round-trip, so it emits the 16 + // digits that re-read to the same double and pads with zeros, and those + // padded digits name an integer no double holds. Passing one back would + // have to cross the argument boundary as a JSON number, so the SDK would + // document a value no program can pass. This assertion is what separates + // the two spellings; the 1e21 case below separates them again on the other + // failure mode, where `String` gives no integer literal at all. expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 60 })).toBe('Literal[1152921504606846976]') expect(jsonSchemaToPy({ type: 'integer', enum: [2 ** 60, -(2 ** 60)] })) .toBe('Literal[1152921504606846976, -1152921504606846976]') From 5d65686c33c43c2716aae043ac5aafbadce7d5e8 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 20:03:39 +0800 Subject: [PATCH 081/190] feat(tools): accept Unicode Python identifiers in the Python SDK renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identifier test was ASCII-only, so an object with a `路径` field degraded to dict[str, Any] -- dropping every sibling field's name, requiredness and type, with no native schema behind it in Code Mode to carry them. Python identifiers are `xid_start xid_continue*`, so match that instead, and widen camelCase's split and head check to the same sets (naming `_` explicitly in the split, since it is XID_Continue). NFKC stability is a second and separate condition. CPython normalizes identifiers at compile time while a JSON key is compared as written, so a U+FB01 ligature key would be declared and reachable under its ASCII expansion, a key the tool never accepts, and two keys that normalize together would collapse into one declaration. Those names take the subscript path. Generated class names are normalized instead of rejected -- they are never matched against a key. Astral characters can now reach the class-name cap, whose slice counts UTF-16 code units, so drop a split surrogate half. Also fix two comment claims. The note said one projection reads the runtime twice per tool; the language-aware getters are installed on run_code's own definition, so it is twice, both for that schema. And the 182-bracket site's reachability is an array reached from the root through oneOf arms alone -- a union spine of any depth, not just one root union; an object ancestor restarts the chain at the 181 site. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 75 ++++++++-- packages/core/tools/tests/py-types.spec.ts | 130 ++++++++++++++++-- 5 files changed, 187 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index e95ce168ca..d1977c65cc 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: c2010ec368da82d8c41df8d00a8e32f0064afde3 -2026-07-31-code-mode-language-dispatch.zh.md: 3cc3bae8c683e8434f48dd251b9dd5dd580bc3ce +2026-07-31-code-mode-language-dispatch.md: b999150ae478eef5396e5456e33ffb041f1b161d +2026-07-31-code-mode-language-dispatch.zh.md: 12ef8197e64e9e8a435f852168ab791029534e7d diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index c2010ec368..b999150ae4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -41,4 +41,4 @@ Adding a backend language is two table entries — an `SDK_RENDERERS` entry and The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. -Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both per definition, so one projection reads the runtime twice per tool; a reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. +Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both, so one projection reads the runtime twice; both reads are for `run_code`'s own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 3cc3bae8c6..12ef8197e6 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -41,4 +41,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 -Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 对每个 definition 解构这两个字段,因此一次投影对每个工具读两次运行时;在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 +Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 5021995b09..b0de1b7a0d 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -17,8 +17,34 @@ import { assertSupportedJsonSchema } from './json-schema.ts' import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' import type { ToolSdkSchema } from './ts-types.ts' -/** Property names that are valid bare Python identifiers; anything else is subscripted. */ -const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ +/** The reference grammar's `xid_start xid_continue*`, the same set `str.isidentifier()` accepts. */ +const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u + +/** + * Whether a name can be emitted as a bare Python identifier rather than + * routed to the subscript/`dict[str, Any]` path. + * + * Python identifiers are not ASCII: `路径` is as legal a field name as `path`, + * and rejecting it would degrade the whole enclosing object, dropping every + * field's name, requiredness, and type — and in Code Mode the native schemas + * are omitted, so this text is the model's only source for them. + * + * NFKC stability is a second and separate condition, because CPython + * normalizes identifiers at compile time while JSON keys are compared as + * written: `field` would be declared and reachable as `field`, so the SDK would + * advertise a key under a spelling the harness never accepts, and two keys + * that normalize together would collapse into one declaration. Those names + * take the subscript path, which carries their exact bytes. + * + * The `ts-types` sibling keeps its own ASCII rule rather than sharing this + * one: ECMAScript identifiers are a different set (`$`, ZWJ/ZWNJ) and are + * never normalized, so one predicate cannot be correct for both. + * @param name - the raw schema field or tool name. + * @returns whether the name can be emitted bare. + */ +function isBareIdentifier(name: string): boolean { + return IDENTIFIER.test(name) && name.normalize('NFKC') === name +} /** * Python hard keywords: reserved everywhere, so a tool or field named @@ -32,8 +58,7 @@ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ * one syntactic position — a statement head (``match``, ``type``), a ``match`` * statement's clause head (``case``), or a pattern (``_``) — so ``match: str`` * as a field and ``async def match(...)`` as a method are both legal, and - * including - * them would needlessly degrade common search/regex tool fields to + * including them would needlessly degrade common search/regex tool fields to * ``dict[str, Any]``. Underscore-leading names are handled separately, not * here: a non-dunder ``__token`` name-mangles, a dunder present on * ``object``/``type`` resolves before the proxy hook, and implicit @@ -156,14 +181,26 @@ function docLines(description: unknown, indent: number): string[] { return [`${pad(indent)}"""${escaped}"""`] } -/** CamelCase a name into a Python type identifier (non-identifier chars split words; a non-letter head is prefixed). */ +/** + * CamelCase a name into a Python type identifier: non-identifier characters + * split words, `_` splits too (it is `XID_Continue`, so the split set names it + * explicitly), and a head that cannot start an identifier takes a `Tool` + * prefix. Unicode survives, so a `路径` field yields `路径`-based class names + * instead of collapsing to the bare prefix. The result is NFKC-normalized: + * these names are generated, never matched against a JSON key, so normalizing + * is free here and keeps what CPython compiles identical to what is emitted — + * unlike {@link isBareIdentifier}, which must reject unstable names outright. + * @param raw - the schema field or tool name to derive from. + * @returns a class-name segment safe to emit. + */ function camelCase(raw: string): string { const joined = raw - .split(/[^A-Za-z0-9]+/) + .split(/[^\p{XID_Continue}]+|_+/u) .filter(part => part.length > 0) .map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join('') - return /^[A-Za-z]/.test(joined) ? joined : `Tool${joined}` + .normalize('NFKC') + return /^\p{XID_Start}/u.test(joined) ? joined : `Tool${joined}` } /** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */ @@ -191,9 +228,11 @@ const MAX_CLASS_NAME_BASE = 120 * - Argument annotation, `async def f(self, args: chain) -> Y:` — the `(` IS * still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the * worst case. Reachable only through a raw `register()` whose `parameters` - * root opens an array chain — rooted at the array, or at an array branch of - * a root `oneOf`, which inherits the enclosing depth because a union adds no - * brackets. `defineTool` compiles an object root, so the annotation is a + * is an array reached from the root through `oneOf` arms alone — the root + * array itself, or one nested under any depth of unions, since an arm + * inherits the enclosing depth unchanged (`A | B` opens no bracket). An + * object ancestor takes it out of this case: its fields restart the chain at + * the 181 site. `defineTool` compiles an object root, so the annotation is a * bare TypedDict class name or a one-bracket `dict[str, Any]` when that * object degrades — never a chain. * @@ -208,9 +247,17 @@ const MAX_CLASS_NAME_BASE = 120 */ const MAX_LIST_NESTING = 180 -/** Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for why capping keeps the render linear). */ +/** + * Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for + * why capping keeps the render linear). `slice` counts UTF-16 code units, so + * an astral character straddling the boundary would be cut in half and leave a + * lone surrogate — not an identifier character, and not even well-formed text; + * drop it rather than emit it. + */ function capClassNameBase(base: string): string { - return base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base + if (base.length <= MAX_CLASS_NAME_BASE) return base + const capped = base.slice(0, MAX_CLASS_NAME_BASE) + return /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped } /** @@ -520,7 +567,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str // NAME-MANGLED inside class syntax (`_ClassName__token`), describing a // different JSON key than the registered schema — degrade like any // other inexpressible field name. - if (className === '' || !entries.every(([name]) => IDENTIFIER.test(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) { + if (className === '' || !entries.every(([name]) => isBareIdentifier(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) { state.typing.add('Any') finish('dict[str, Any]') break @@ -623,7 +670,7 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { for (const schema of sorted) { const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state) const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state) - if (IDENTIFIER.test(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) { + if (isBareIdentifier(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) { // A docstring only documents its method when it is the FIRST statement // of that method's body. Emitted before the `async def` it would instead // become the `Tools` class docstring (for the first tool) or a dead diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 60291aa026..ca5ca40ce8 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -398,6 +398,106 @@ describe('renderToolsSdkPy', () => { expect(text).not.toContain('dict[str, Any]') }) + it('keeps a non-ASCII field name as a TypedDict field and derives its class name from it', () => { + // `路径` satisfies `xid_start xid_continue*`, so CPython accepts it as an + // attribute and as the `TypedDict` key. Rejecting it would degrade the + // whole object, dropping every SIBLING field's name, requiredness and type + // too — and Code Mode omits the native schemas, so nothing else carries + // them. The nested class name is derived from the field, so `camelCase` + // has to pass the same characters through instead of splitting on them. + const tool: ToolSdkSchema = { + name: '搜索', + description: 'Unicode identifiers.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + 路径: { type: 'string' }, + opts: { type: 'object', additionalProperties: false, properties: { 深度: { type: 'number' } } }, + }, + required: ['路径'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('async def 搜索(self, args: 搜索Args) -> str:') + expect(text).toContain('class 搜索Args(TypedDict):') + expect(text).toContain(' 路径: str') + expect(text).toContain('class 搜索ArgsOpts(TypedDict):') + expect(text).toContain(' 深度: NotRequired[float]') + expect(text).not.toContain('dict[str, Any]') + }) + + it('degrades a field name that NFKC-normalizes to something else, which would be declared under another spelling', () => { + // U+FB01 LATIN SMALL LIGATURE FI passes the identifier grammar, but CPython + // normalizes identifiers at compile time while the harness compares the + // JSON key as written: `field: str` would declare and be reachable as + // `field`, a key the tool never accepts. Two keys that normalize together + // would additionally collapse into one declaration. The subscript path + // carries the exact bytes instead. + const text = renderToolsSdkPy([ + { + name: 'ligature', + description: 'Normalizing field name.', + parameters: { type: 'object', additionalProperties: false, properties: { field: { type: 'string' } } }, + output: { type: 'string' }, + }, + ]) + expect(text).toContain('async def ligature(self, args: dict[str, Any]) -> str:') + expect(text).not.toContain('field:') + expect(text).not.toContain('field:') + }) + + it('subscripts a tool name that NFKC-normalizes to something else, while declaring a plain Unicode one', () => { + // Same split at the tool-name site: `路径` becomes an `async def`, the + // ligature name cannot, because `async def find` would define `find`. The + // subscript comment quotes the name, so its exact bytes survive, and its + // TypedDict is still named and referenced — the name is only unusable as a + // method, not as a class-name source (`camelCase` normalizes what it + // derives, since a generated name is never matched against a JSON key). + const of = (name: string): ToolSdkSchema => ({ + name, + description: `Tool ${name}.`, + parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] }, + output: { type: 'string' }, + }) + const text = renderToolsSdkPy([of('路径'), of('find')]) + expect(text).toContain('async def 路径(self, args: 路径Args) -> str:') + expect(text).toContain('# tools["find"](args: FIndArgs) -> str') + expect(text).toContain('class FIndArgs(TypedDict):') + expect(text).not.toContain('async def find') + expect(text).not.toContain('async def find') + }) + + it('drops a surrogate half rather than cutting a pair when capping an astral class-name base', () => { + // Class-name bases are capped by `slice`, which counts UTF-16 code units, + // so a boundary landing inside an astral pair would leave a lone high + // surrogate — not an identifier character, and not encodable text. Padding + // with one ASCII character shifts the boundary onto the pair. + // U+10330 GOTHIC LETTER AHSA: XID_Start and NFKC-stable, unlike `𝕏`, which + // NFKC-folds to ASCII `X` and so never reaches the boundary at all. + const AHSA = String.fromCodePoint(0x10330) + const className = (pad: string): string => { + const text = renderToolsSdkPy([ + { + name: `${pad}${AHSA.repeat(200)}`, + description: 'Astral name.', + parameters: { type: 'object', additionalProperties: false, properties: { a: { type: 'string' } } }, + output: { type: 'string' }, + }, + ]) + // The base is `${camelCase(name)}Args` capped to 120 code units, so the + // `Args` suffix itself is cut off here; match the declaration instead. + return /^class (.+)\(TypedDict\):$/mu.exec(text)![1]! + } + // Each character is 2 code units, so an unpadded name fills the cap with 60 + // whole characters; one ASCII character of padding puts the boundary inside + // the 60th pair, and that half is dropped rather than emitted. + expect(className('')).toBe(AHSA.repeat(60)) + expect(className('x')).toBe(`X${AHSA.repeat(59)}`) + expect(className('x')).toHaveLength(119) + }) + it('declares a closed empty object with omitted properties as an empty TypedDict, not dict[str, Any]', () => { // `{ type: 'object', additionalProperties: false }` with no `properties` // is a closed empty object — no key accepted — exactly as the validator @@ -580,8 +680,10 @@ describe('renderToolsSdkPy', () => { // The worst of the three emission sites: the parameter list's `(` is still // open around this annotation, so 180 `list[` plus the innermost bracket // plus that paren is 182 of CPython's 200. Only a raw `register()` whose - // `parameters` root opens an array chain reaches it — rooted at the array, - // or at an array branch of a root `oneOf`, since a union adds no brackets. + // `parameters` is an array reached from the root through `oneOf` arms + // alone gets there — the root array itself, or one under any depth of + // unions, since an arm inherits the enclosing depth unchanged. An object + // ancestor takes it out of this case: its fields restart at the 181 site. // `defineTool` compiles an object root, whose annotation is a bare // TypedDict name or a one-bracket `dict[str, Any]`, never a chain. const rooted = (depth: number): ToolSdkSchema => { @@ -602,13 +704,25 @@ describe('renderToolsSdkPy', () => { // rather than on another `list[`, so the count cannot grow past that. expect(renderToolsSdkPy([rooted(181)])) .toContain(`async def rooted(self, args: ${'list['.repeat(180)}Any${']'.repeat(180)}) -> str:`) - // A root union reaches the same 182: its branches inherit the enclosing - // depth because `A | B` opens nothing, so the chain under one of them - // starts at 0 exactly as the array-rooted case does. - const union = { ...rooted(180), parameters: { oneOf: [rooted(180).parameters, { type: 'string' }] } } - const text = renderToolsSdkPy([union]) - expect(text).toContain(`args: ${'list['.repeat(180)}Literal["x"]${']'.repeat(180)} | str) -> str:`) + // A union spine reaches the same 182, at any number of arms deep: each arm + // inherits the enclosing depth because `A | B` opens nothing, so the chain + // under the innermost one still starts at 0. Three unions here, to pin that + // it is the whole `oneOf`-only path and not just a single root union. + let spine: Record<string, unknown> = rooted(180).parameters + for (let i = 0; i < 3; i++) spine = { oneOf: [spine, { type: 'string' }] } + const text = renderToolsSdkPy([{ ...rooted(180), parameters: spine }]) + const chain = `${'list['.repeat(180)}Literal["x"]${']'.repeat(180)}` + expect(text).toContain(`args: ${chain} | str | str | str) -> str:`) expect(text.split('async def rooted(self, args: ')[1]!.split(') -> str:')[0]!.split('[').length - 1).toBe(181) + // An object ancestor is the boundary of that path: the field it declares is + // a class-body line, so the same chain lands on the 181 site instead. + const boxed = renderToolsSdkPy([ + { + ...rooted(180), + parameters: { type: 'object', properties: { rows: rooted(180).parameters }, required: ['rows'] }, + }, + ]) + expect(boxed).toContain(` rows: ${'list['.repeat(179)}Any${']'.repeat(179)}`) }) it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => { From 2cb0dddb4084a534b71254a08afdfeada210dcbe Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 20:06:06 +0800 Subject: [PATCH 082/190] docs(tools): stop over-quantifying what String does to a big integral double MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pyScalar paragraph read as a universal over every beyond-safe-range integral number, and three of its clauses have counterexamples inside that very domain: String(2 ** 53) and String(1e20) are byte-identical to BigInt's digits, so the "different integer or no integer literal at all" split is not exhaustive, "the 16 digits" is 2 ** 60's instance count rather than the mechanism (shortest round-trip is 1 to 17 significant digits), and padded digits do name a held integer for 1e20. Say shortest decimal string then padded to the exponent, give both counts, condition the no-double-holds-it clause, and state the invariant that makes the rule unconditional: where String is already exact the two agree, and where it is not, BigInt is the exact one. Also align one README.zh.md term: the same file already translates "exotic names" as 特殊名称 in the SDK-section bullet. --- packages/core/tools/README.i18n.yaml | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/py-types.ts | 24 ++++++++++++++---------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index fb90efa1db..f5a9234f1e 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md README.md: 20df93e734afb9e7f4280d3aa208af2c8338001c -README.zh.md: d16a8a90c626c746b8629d148e432302f72b5f30 +README.zh.md: a9741673b7283a78223fb9523abef022a79638e4 diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d16a8a90c6..a9741673b7 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -145,7 +145,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、异体名用下标访问、`print(...)` 与顶层 `return`)。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index b0de1b7a0d..315afa5aa6 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -304,16 +304,20 @@ function childClassName(base: string, segment: string): string { * * A beyond-safe-range integral number takes `BigInt` digits rather than * `String`: Python integers are arbitrary-precision, so the emitted digits ARE - * the value the model programs against, and `String` gives a different integer - * than the double holds (`2 ** 60` prints the rounded `...847000`, not the - * exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`). - * `String`'s rounding is not a bug in it: `Number::toString` is shortest - * round-trip, so it emits the 16 digits that re-read to the same double and - * pads with zeros, and those padded digits name an integer no double holds. - * Passing one back would have to cross the argument boundary as a JSON number - * — a double again — so the SDK would document a value no program can pass. - * The TS flavor needs no counterpart: its literal is re-read by a JS parser - * back into the same double. + * the value the model programs against, and `String` can give a different + * integer than the double holds (`2 ** 60` prints the rounded `...847000`, not + * the exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`). + * `String`'s rounding is not a bug in it: `Number::toString` emits the shortest + * decimal string that re-reads to the same double, then pads to the exponent + * with zeros (1 significant digit for `1e20`, 16 for `2 ** 60`) — and when the + * shortest string is shorter than the double's exact value, those padded digits + * name an integer no double holds. Passing one back would have to cross the + * argument boundary as a JSON number — a double again — so the SDK would + * document a value no program can pass. `BigInt` needs no case split: where + * `String` is already exact (`2 ** 53`, `1e20`) the two agree byte for byte, + * and where it is not, `BigInt` is the exact one. The TS flavor needs no + * counterpart at all: its literal is re-read by a JS parser back into the same + * double. * * `JSON.stringify` is also what keeps this path's output parseable, and it is * the only thing that does. It covers both classes of hazard: the two kinds of From 8c001d992801e3852e8013b32a9b8a486024cc47 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 20:39:11 +0800 Subject: [PATCH 083/190] fix(tools): normalize the two class-name joins camelCase's own call misses camelCase normalized `joined` and then prefixed, so the seam the `Tool` prefix creates was never covered: `Tool` ends in `l`, a combining-mark head composes with it, and a name headed by U+0301 was emitted as `Tool` + U+0301 while CPython compiles `Too` + U+013A. childClassName has the same shape -- both sides separately NFKC-stable, their join not: a base ending in a Hangul L jamo or LV syllable composes with a V or T jamo head. Beyond the declared-name/compiled-symbol mismatch, two byte-distinct names can fold onto one, and usedClassNames dedupes by raw bytes, so the collision counter never sees it. Normalize after the prefix decision and at the join, before the cap. The remaining joins need nothing: `Args`/`Output` and the digit suffix cannot compose backwards. Also record the Unicode-table skew. The predicate reads the engine's tables (Node 22.23.1: 17.0) and the interpreter reads its own (CPython 3.9.6: 13.0.0), so an interpreter older than the engine takes a bare name its tokenizer refuses -- U+1C89, U+10570, U+1E290 and U+1E4D0 are accepted here and rejected there. The other direction only degrades a legal name to subscript. Closing it needs the CPython floor, which the backend PR owns; state the asymmetry in the docstring and make the decision an explicit obligation in the note. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 + ...26-07-31-code-mode-language-dispatch.zh.md | 2 + packages/core/tools/src/py-types.ts | 55 ++++++++++++++-- packages/core/tools/tests/py-types.spec.ts | 65 ++++++++++++++++++- 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index d1977c65cc..2282e1dace 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: b999150ae478eef5396e5456e33ffb041f1b161d -2026-07-31-code-mode-language-dispatch.zh.md: 12ef8197e64e9e8a435f852168ab791029534e7d +2026-07-31-code-mode-language-dispatch.md: bc56736c1582b89b4c16b76c49762eeaf0c3fc39 +2026-07-31-code-mode-language-dispatch.zh.md: 9a224cbda75ce530f18498a8e1b0ca42ab540ee1 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index b999150ae4..bc56736c15 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -42,3 +42,5 @@ Adding a backend language is two table entries — an `SDK_RENDERERS` entry and The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both, so one projection reads the runtime twice; both reads are for `run_code`'s own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. + +Third, that PR owns the CPython floor, and with it the Unicode-table skew in `isBareIdentifier`. This renderer decides whether a field or tool name can be emitted bare using the running engine's `\p{XID_Start}`/`\p{XID_Continue}` tables (Node 22.23.1: Unicode 17.0), while the interpreter uses its own (CPython 3.9.6: 13.0.0). An interpreter older than the engine is the failing direction: a character added to `XID_Start` in between is emitted bare and its tokenizer refuses the whole block. The exposure window is exactly the characters added between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and tightening the predicate against pinned tables for that floor. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 12ef8197e6..9a224cbda7 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -42,3 +42,5 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 + +其三,那个 PR 拥有 CPython 版本下限,连带拥有 `isBareIdentifier` 里的 Unicode 表偏斜。本渲染器用所运行引擎的 `\p{XID_Start}`/`\p{XID_Continue}` 表(Node 22.23.1:Unicode 17.0)决定某个字段名或工具名能否裸发,而解释器用它自己的表(CPython 3.9.6:13.0.0)。解释器旧于引擎是会失败的那个方向:在两者之间被加进 `XID_Start` 的字符会被裸发,其 tokenizer 拒收,整个块随之不可解析。暴露窗口恰是两个版本之间新增的那些字符,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的固定表收紧判据」之间显式作出决定。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 315afa5aa6..9ed7d75d4d 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -17,7 +17,11 @@ import { assertSupportedJsonSchema } from './json-schema.ts' import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' import type { ToolSdkSchema } from './ts-types.ts' -/** The reference grammar's `xid_start xid_continue*`, the same set `str.isidentifier()` accepts. */ +/** + * The reference grammar's `xid_start xid_continue*` — the set + * `str.isidentifier()` accepts on a CPython whose Unicode tables match the + * engine's. See {@link isBareIdentifier} for what a version skew does. + */ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u /** @@ -36,6 +40,24 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * that normalize together would collapse into one declaration. Those names * take the subscript path, which carries their exact bytes. * + * Both conditions are evaluated against the ENGINE's Unicode tables, and the + * two sides are versioned independently — `\p{XID_Start}` follows the running + * engine (Node 22.23.1 reports Unicode 17.0) while CPython follows its own + * (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython older than the + * engine is the dangerous direction: a character added to `XID_Start` since its + * tables (U+1C89, U+10570, U+1E290, U+1E4D0 are all NFKC-stable and accepted + * here, and all rejected by that 3.9.6) is emitted bare and its tokenizer + * refuses the character, taking the whole SDK block down — the same + * parseability invariant {@link UNPRINTABLE}, {@link LONE_SURROGATE} and + * {@link MAX_LIST_NESTING} exist for. A CPython newer than the engine only + * routes a legal name to the subscript path: less readable, still correct. The + * NFKC condition reduces to the same skew, since normalization stability + * guarantees an assigned character's normalization never changes afterwards. + * + * Closing the exposure needs the target interpreter's version, which the + * backend reporting `language: 'python'` owns and which is unpublished on this + * base; the note records it as that PR's decision. + * * The `ts-types` sibling keeps its own ASCII rule rather than sharing this * one: ECMAScript identifiers are a different set (`$`, ZWJ/ZWNJ) and are * never normalized, so one predicate cannot be correct for both. @@ -186,10 +208,19 @@ function docLines(description: unknown, indent: number): string[] { * split words, `_` splits too (it is `XID_Continue`, so the split set names it * explicitly), and a head that cannot start an identifier takes a `Tool` * prefix. Unicode survives, so a `路径` field yields `路径`-based class names - * instead of collapsing to the bare prefix. The result is NFKC-normalized: - * these names are generated, never matched against a JSON key, so normalizing - * is free here and keeps what CPython compiles identical to what is emitted — - * unlike {@link isBareIdentifier}, which must reject unstable names outright. + * instead of collapsing to the bare prefix. A character that is not + * `XID_Continue` splits even when it is a letter, so a name whose NFKC folding + * would leave the identifier set is not carried through — the split set is the + * grammar's, not an ASCII approximation of it. + * + * The result is NFKC-normalized: these names are generated, never matched + * against a JSON key, so normalizing is free here and keeps what CPython + * compiles identical to what is emitted — unlike {@link isBareIdentifier}, + * which must reject unstable names outright. Normalizing AFTER the prefix + * decision is what makes that hold at the seam the prefix creates: `Tool` + + * a combining-mark head composes there (`U+0301` gives `Tooĺ`, U+013A), so + * normalizing only the un-prefixed part would emit a name CPython compiles to + * a different symbol. The second call is idempotent on the un-prefixed arm. * @param raw - the schema field or tool name to derive from. * @returns a class-name segment safe to emit. */ @@ -200,7 +231,7 @@ function camelCase(raw: string): string { .map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join('') .normalize('NFKC') - return /^\p{XID_Start}/u.test(joined) ? joined : `Tool${joined}` + return (/^\p{XID_Start}/u.test(joined) ? joined : `Tool${joined}`).normalize('NFKC') } /** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */ @@ -291,9 +322,19 @@ function allocateClassName(base: string, state: RenderState): string { * object-chain would otherwise carry an ever-growing ConsString down the tree * and re-materialize it (via `.length`/`.slice`) at every level — Θ(depth²). * The bounded base plus the collision counter still yields unique names. + * + * The join is NFKC-normalized because both sides are separately normalized yet + * their concatenation need not be: a base ending in a Hangul L jamo or LV + * syllable composes with a following V or T jamo head (`가` + `ᆨ` gives `각`), + * so the emitted class name would differ from the symbol CPython compiles, and + * two byte-distinct names could fold onto one — `usedClassNames` dedupes by the + * raw bytes, so the collision counter would not see it. Normalizing costs + * O(cap + segment) per level, the same order as the `slice` it feeds. The other + * two join points need no counterpart: `Args`/`Output` start with `A`/`O` and + * {@link allocateClassName}'s suffix is digits, none of which compose backwards. */ function childClassName(base: string, segment: string): string { - return capClassNameBase(`${base}${segment}`) + return capClassNameBase(`${base}${segment}`.normalize('NFKC')) } /** diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index ca5ca40ce8..2b73ec5795 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -494,8 +494,69 @@ describe('renderToolsSdkPy', () => { // whole characters; one ASCII character of padding puts the boundary inside // the 60th pair, and that half is dropped rather than emitted. expect(className('')).toBe(AHSA.repeat(60)) - expect(className('x')).toBe(`X${AHSA.repeat(59)}`) - expect(className('x')).toHaveLength(119) + const padded = className('x') + expect(padded).toBe(`X${AHSA.repeat(59)}`) + expect(padded).toHaveLength(119) + }) + + it('normalizes the seam the Tool prefix creates, which the prefixed part alone does not cover', () => { + // U+0301 COMBINING ACUTE ACCENT is XID_Continue but not XID_Start, so a name + // headed by it takes the `Tool` prefix — and `Tool` ends in `l`, which + // composes with it. Normalizing only the part being prefixed would emit + // `Tool` + U+0301, which CPython compiles as `Too` + U+013A: the class + // the SDK declares would not be the class the interpreter defines. Every + // code point below is an escape — the two forms render identically. + const text = renderToolsSdkPy([ + { + name: '\u0301abc', + description: 'Combining-mark head.', + parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] }, + output: { type: 'string' }, + }, + ]) + expect(text).toContain('class Too\u013AabcArgs(TypedDict):') + expect(text).toContain('# tools["\u0301abc"](args: Too\u013AabcArgs) -> str') + expect(text).not.toContain('Tool\u0301') + }) + + it('normalizes a class-name join where two separately stable segments compose', () => { + // Hangul jamo compose ACROSS the join `childClassName` makes: the parent + // base ends in U+1100 (L jamo) and the child segment starts with U+1161 (V + // jamo), each NFKC-stable alone, together U+AC00. Unnormalized, the declared + // name differs from the compiled symbol, and two byte-distinct names can + // fold onto one — `usedClassNames` dedupes by raw bytes, so the collision + // counter never sees it and the later declaration shadows the earlier one + // under CPython. Escapes again, for the same reason as above. + const text = renderToolsSdkPy([ + { + name: 'x', + description: 'Jamo field names.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['\uAC00\u1100'], + properties: { + '\uAC00\u1100': { + type: 'object', + additionalProperties: false, + required: ['\u1161x'], + properties: { + '\u1161x': { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } } }, + }, + }, + }, + }, + output: { type: 'string' }, + }, + ]) + // The join is `XArgs` + U+AC00 U+1100 followed by U+1161 `x`, whose + // trailing L+V pair composes into a second U+AC00. + expect(text).toContain('class XArgs\uAC00\uAC00x(TypedDict):') + expect(text).toContain(' \u1161x: XArgs\uAC00\uAC00x') + expect(text).not.toContain('\u1100\u1161') + // The level above it is a join that composes nothing (LV + L), so it stays + // byte-identical — normalizing is not silently rewriting every name. + expect(text).toContain('class XArgs\uAC00\u1100(TypedDict):') }) it('declares a closed empty object with omitted properties as an empty TypedDict, not dict[str, Any]', () => { From 2914a87eda5f8ae9fc2a05253a34f74f37e53602 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 21:08:24 +0800 Subject: [PATCH 084/190] fix(tools): widen the Unicode-skew obligation past isBareIdentifier The predicate is not the only reader of the engine's XID tables. camelCase reads them through its split set and its head test, and the class name it derives is emitted for EVERY tool -- including one the predicate rejected, whose TypedDict is still declared and named. A tool named `zz-` + U+1E4D0 never reaches the skew in the predicate, since the `-` rejects it outright, yet still emits `class Zz<U+1E4D0>xArgs`, which CPython 3.9.6 refuses the same way. A backend PR executing "pin the predicate against tables for the floor" literally would leave that path open, so the note and the docstring now name all three read points. Two corrections in the same paragraph. The failing direction is a character added to XID_Start OR XID_Continue -- one added only to the latter passes the trailing `\p{XID_Continue}*` in a tail position and fails identically. And the safe direction routes a name to the subscript/`dict[str, Any]` path: a rejected FIELD name degrades its whole enclosing object rather than just itself, which the predicate's opening paragraph already said. Also qualify the module header's "ONLY source" claim, which holds under `mode: 'code'` but not `both`, where wireSchemas ships every native schema alongside the SDK section; record the measured str.isidentifier() equivalence (21 samples, zero divergence, Node 22.23.1 vs CPython 3.9.6) where the versions it is relative to already live; and attribute the `FInd` spelling in the ligature test to full case mapping rather than to the NFKC step, which is the identity there. Two tests. The fold-collision half of the childClassName fix: sibling joins that are byte-distinct before NFKC and equal after, so `usedClassNames` dedupes by raw bytes and the counter only sees the collision because the join is normalized. And the argument-side oneOf-of-objects branch naming, which reaches the same childClassName path the output side already pins. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 66 ++++++++++++------- packages/core/tools/tests/py-types.spec.ts | 65 +++++++++++++++++- 5 files changed, 110 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 2282e1dace..1832263d0f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: bc56736c1582b89b4c16b76c49762eeaf0c3fc39 -2026-07-31-code-mode-language-dispatch.zh.md: 9a224cbda75ce530f18498a8e1b0ca42ab540ee1 +2026-07-31-code-mode-language-dispatch.md: 52ec905b871d4a4954e1b33d3422a797307b75bc +2026-07-31-code-mode-language-dispatch.zh.md: 94361744fbfcd7b7fb5d6bc94e3da3aae3403aeb diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index bc56736c15..52ec905b87 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -43,4 +43,4 @@ The cost is that the Python branch of both tables is unreachable on this base: ` Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both, so one projection reads the runtime twice; both reads are for `run_code`'s own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. -Third, that PR owns the CPython floor, and with it the Unicode-table skew in `isBareIdentifier`. This renderer decides whether a field or tool name can be emitted bare using the running engine's `\p{XID_Start}`/`\p{XID_Continue}` tables (Node 22.23.1: Unicode 17.0), while the interpreter uses its own (CPython 3.9.6: 13.0.0). An interpreter older than the engine is the failing direction: a character added to `XID_Start` in between is emitted bare and its tokenizer refuses the whole block. The exposure window is exactly the characters added between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and tightening the predicate against pinned tables for that floor. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. +Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Three regexes read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set and head test. An interpreter older than the engine is the failing direction — a character added to `XID_Start` or `XID_Continue` in between is emitted and its tokenizer refuses the whole block — and it arrives by two independent paths. Through the predicate, a bare method or field name. Through `camelCase`, a class name, which is emitted for every tool including one the predicate rejected: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. The exposure window is exactly the characters added between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all three read points to tables for that floor — pinning the predicate alone leaves the class-name path open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 9a224cbda7..94361744fb 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -43,4 +43,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 -其三,那个 PR 拥有 CPython 版本下限,连带拥有 `isBareIdentifier` 里的 Unicode 表偏斜。本渲染器用所运行引擎的 `\p{XID_Start}`/`\p{XID_Continue}` 表(Node 22.23.1:Unicode 17.0)决定某个字段名或工具名能否裸发,而解释器用它自己的表(CPython 3.9.6:13.0.0)。解释器旧于引擎是会失败的那个方向:在两者之间被加进 `XID_Start` 的字符会被裸发,其 tokenizer 拒收,整个块随之不可解析。暴露窗口恰是两个版本之间新增的那些字符,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的固定表收紧判据」之间显式作出决定。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。 +其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有三个正则读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集与头部测试。解释器旧于引擎是会失败的那个方向——在两者之间被加进 `XID_Start` 或 `XID_Continue` 的字符会被发出,其 tokenizer 拒收,整个块随之不可解析——而它经两条独立路径抵达。经判据抵达的是裸发的方法名或字段名。经 `camelCase` 抵达的是类名,而类名对每个工具都发出,包括被判据拒绝的那些:工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。暴露窗口恰是两个版本之间新增的那些字符,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部三个读取点」之间显式作出决定——只钉判据会留下类名那条路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 9ed7d75d4d..c50ba657c1 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -5,11 +5,12 @@ * store, keyed by the loaded {@link @deepseek-ai/dsh-code-runtime#CodeRuntime.language | code * runtime's language}. * - * In Code Mode the native tool schemas are omitted from the request, so this generated SDK is - * the model's ONLY source for each tool's argument names, required fields, types, descriptions, - * and canonical output shapes. Object-shaped arguments and outputs therefore render as one named - * `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the shape - * survives into the program. + * Under `mode: 'code'` the native tool schemas are omitted from the request, so this generated + * SDK is the model's ONLY source for each tool's argument names, required fields, types, + * descriptions, and canonical output shapes; under `mode: 'both'` the native schemas ship + * alongside it and it is one of two. Object-shaped arguments and outputs therefore render as one + * named `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the + * shape survives into the program under the mode that has nothing else to carry it. * @module @deepseek-ai/dsh-tools/src/py-types */ @@ -30,8 +31,8 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * * Python identifiers are not ASCII: `路径` is as legal a field name as `path`, * and rejecting it would degrade the whole enclosing object, dropping every - * field's name, requiredness, and type — and in Code Mode the native schemas - * are omitted, so this text is the model's only source for them. + * field's name, requiredness, and type — which under `mode: 'code'` is the + * model's only source for them. * * NFKC stability is a second and separate condition, because CPython * normalizes identifiers at compile time while JSON keys are compared as @@ -40,23 +41,37 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * that normalize together would collapse into one declaration. Those names * take the subscript path, which carries their exact bytes. * - * Both conditions are evaluated against the ENGINE's Unicode tables, and the - * two sides are versioned independently — `\p{XID_Start}` follows the running - * engine (Node 22.23.1 reports Unicode 17.0) while CPython follows its own - * (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython older than the - * engine is the dangerous direction: a character added to `XID_Start` since its - * tables (U+1C89, U+10570, U+1E290, U+1E4D0 are all NFKC-stable and accepted - * here, and all rejected by that 3.9.6) is emitted bare and its tokenizer - * refuses the character, taking the whole SDK block down — the same - * parseability invariant {@link UNPRINTABLE}, {@link LONE_SURROGATE} and - * {@link MAX_LIST_NESTING} exist for. A CPython newer than the engine only - * routes a legal name to the subscript path: less readable, still correct. The - * NFKC condition reduces to the same skew, since normalization stability - * guarantees an assigned character's normalization never changes afterwards. + * The equivalence to `str.isidentifier()` was measured across 21 samples with + * zero divergence, on Node 22.23.1 against CPython 3.9.6 — the halves the two + * conditions are proxies for, both tested by that run. * - * Closing the exposure needs the target interpreter's version, which the - * backend reporting `language: 'python'` owns and which is unpublished on this - * base; the note records it as that PR's decision. + * Both conditions are evaluated against the ENGINE's Unicode tables, and the + * two sides are versioned independently — `\p{XID_Start}`/`\p{XID_Continue}` + * follow the running engine (Node 22.23.1 reports Unicode 17.0) while CPython + * follows its own (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython + * older than the engine is the dangerous direction: a character added to + * either property since its tables (U+1C89, U+10570, U+1E290, U+1E4D0 are all + * NFKC-stable and accepted here, and all rejected by that 3.9.6) is emitted + * bare and its tokenizer refuses the character, taking the whole SDK block + * down — the same parseability invariant {@link UNPRINTABLE}, + * {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING} exist for. Both + * properties carry it: a character added only to `XID_Continue` passes the + * trailing `\p{XID_Continue}*` in a tail position and fails the same way. A + * CPython newer than the engine only routes a legal name to the + * subscript/`dict[str, Any]` path: less readable, still correct. The NFKC + * condition reduces to the same skew, since normalization stability guarantees + * an assigned character's normalization never changes afterwards. + * + * This predicate is not the only reader of those tables. {@link camelCase} + * reads them too, through its split set and its head test, and its output is + * emitted for EVERY tool — including one this predicate rejected, whose + * `TypedDict` is still declared and named. A tool named `zz-\u{1E4D0}x` never + * reaches the skew here (the `-` rejects it outright) yet emits + * `class Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses. Closing the + * exposure therefore covers all three read points, not this predicate alone; + * it needs the target interpreter's version, which the backend reporting + * `language: 'python'` owns and which is unpublished on this base, so the note + * records it as that PR's decision. * * The `ts-types` sibling keeps its own ASCII rule rather than sharing this * one: ECMAScript identifiers are a different set (`$`, ZWJ/ZWNJ) and are @@ -221,6 +236,11 @@ function docLines(description: unknown, indent: number): string[] { * a combining-mark head composes there (`U+0301` gives `Tooĺ`, U+013A), so * normalizing only the un-prefixed part would emit a name CPython compiles to * a different symbol. The second call is idempotent on the un-prefixed arm. + * + * The split set and the head test read the engine's Unicode tables, so this + * function carries the same version skew {@link isBareIdentifier} documents, + * by an independent path: a class name derived here is emitted for every tool, + * including one the predicate rejected. * @param raw - the schema field or tool name to derive from. * @returns a class-name segment safe to emit. */ diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 2b73ec5795..a5e2f660f2 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -453,8 +453,11 @@ describe('renderToolsSdkPy', () => { // ligature name cannot, because `async def find` would define `find`. The // subscript comment quotes the name, so its exact bytes survive, and its // TypedDict is still named and referenced — the name is only unusable as a - // method, not as a class-name source (`camelCase` normalizes what it - // derives, since a generated name is never matched against a JSON key). + // method, not as a class-name source. The `FInd` spelling comes from `fi`'s + // multi-character full case mapping (`'fi'.toUpperCase()` is `'FI'`), not + // from `camelCase`'s NFKC step, which is the identity on `FInd`: the + // ligature is XID_Start, so the split set keeps it and only the + // capitalization of the head transforms it. const of = (name: string): ToolSdkSchema => ({ name, description: `Tool ${name}.`, @@ -559,6 +562,64 @@ describe('renderToolsSdkPy', () => { expect(text).toContain('class XArgs\uAC00\u1100(TypedDict):') }) + it('routes a fold collision through the counter that raw-byte dedup would miss', () => { + // The other half of the `childClassName` normalization: two joins that are + // byte-distinct before NFKC and identical after. Field `\uAC00` allocates + // `XArgs\uAC00`; the sibling `\u1100` allocates `XArgs\u1100`, and ITS child + // `\u1161` joins to `XArgs\u1100\u1161` — the same `XArgs\uAC00` once composed. + // Normalizing at the join is what lets `usedClassNames`, which dedupes by raw + // bytes, see the collision at all; unnormalized, both would be declared and + // CPython would compile the second as a shadow of the first. + const text = renderToolsSdkPy([ + { + name: 'x', + description: 'Colliding jamo joins.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['\uAC00', '\u1100'], + properties: { + '\uAC00': { type: 'object', additionalProperties: false, required: ['q'], properties: { q: { type: 'string' } } }, + '\u1100': { + type: 'object', + additionalProperties: false, + required: ['\u1161'], + properties: { + '\u1161': { type: 'object', additionalProperties: false, required: ['q'], properties: { q: { type: 'string' } } }, + }, + }, + }, + }, + output: { type: 'string' }, + }, + ]) + expect(text).toContain('class XArgs\uAC00(TypedDict):') + expect(text).toContain('class XArgs\uAC002(TypedDict):') + expect(text).toContain(' \u1161: XArgs\uAC002') + }) + + it('names both branches of a oneOf of objects on the argument side', () => { + // The output side is pinned elsewhere; arguments reach the same + // `childClassName(frame.className, index + 1)` path, and the annotation is + // the union of the two derived names rather than a degraded dict. + const text = renderToolsSdkPy([ + { + name: 'x', + description: 'Union arguments.', + parameters: { + oneOf: [ + { type: 'object', additionalProperties: false, required: ['a'], properties: { a: { type: 'string' } } }, + { type: 'object', additionalProperties: false, required: ['b'], properties: { b: { type: 'number' } } }, + ], + }, + output: { type: 'string' }, + }, + ]) + expect(text).toContain('class XArgs1(TypedDict):') + expect(text).toContain('class XArgs2(TypedDict):') + expect(text).toContain('async def x(self, args: XArgs1 | XArgs2) -> str:') + }) + it('declares a closed empty object with omitted properties as an empty TypedDict, not dict[str, Any]', () => { // `{ type: 'object', additionalProperties: false }` with no `properties` // is a closed empty object — no key accepted — exactly as the validator From be98a0b978fcf445716c64e686e9c232bf52f702 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 21:33:30 +0800 Subject: [PATCH 085/190] docs(tools): record the case-mapping read point and narrow the class-name quantifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `camelCase`'s `toUpperCase()` is a fourth reader of the engine's Unicode tables, on a table distinct from XID membership and with a wider window: a tool named U+019B passes `isBareIdentifier` and compiles as `async def` on CPython 3.9.6, but Node maps the head to U+A7DC and the declared `class ꟜArgs` fails there with `invalid non-printable character`. Record it alongside the three XID read points in the renderer docs and in the note's CPython-floor obligation, and pin the derivation with a test. Correct three over-quantified sentences: a `camelCase`-derived class name is evaluated for every tool but only reaches emitted text when some object shape in the schema declares a `TypedDict`. Attribute the `str.isidentifier()` equivalence to `IDENTIFIER` rather than to the predicate, which is deliberately stricter, and restore the antecedent the mode qualification dropped. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 45 ++++++++++++------- packages/core/tools/tests/py-types.spec.ts | 21 +++++++++ 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 1832263d0f..372133ad08 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 52ec905b871d4a4954e1b33d3422a797307b75bc -2026-07-31-code-mode-language-dispatch.zh.md: 94361744fbfcd7b7fb5d6bc94e3da3aae3403aeb +2026-07-31-code-mode-language-dispatch.md: 8115ff4465818a4fa5f6cfb3e35630a9d5e14db3 +2026-07-31-code-mode-language-dispatch.zh.md: c52a5167bf82299d1be00c7095ce075d78d9eb88 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 52ec905b87..8115ff4465 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -43,4 +43,4 @@ The cost is that the Python branch of both tables is unreachable on this base: ` Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both, so one projection reads the runtime twice; both reads are for `run_code`'s own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. -Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Three regexes read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set and head test. An interpreter older than the engine is the failing direction — a character added to `XID_Start` or `XID_Continue` in between is emitted and its tokenizer refuses the whole block — and it arrives by two independent paths. Through the predicate, a bare method or field name. Through `camelCase`, a class name, which is emitted for every tool including one the predicate rejected: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. The exposure window is exactly the characters added between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all three read points to tables for that floor — pinning the predicate alone leaves the class-name path open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. +Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set, head test, and `toUpperCase()`. An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name headed or tailed by a character added to `XID_Start`/`XID_Continue` between the two versions. Through `camelCase`'s XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a `TypedDict`, and which the predicate's verdict on the tool name does not gate: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so `async def ƛ` compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own `.upper()` is the identity) and `class ꟜArgs` fails with `invalid non-printable character U+A7DC`. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 94361744fb..c52a5167bf 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -43,4 +43,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 -其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有三个正则读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集与头部测试。解释器旧于引擎是会失败的那个方向——在两者之间被加进 `XID_Start` 或 `XID_Continue` 的字符会被发出,其 tokenizer 拒收,整个块随之不可解析——而它经两条独立路径抵达。经判据抵达的是裸发的方法名或字段名。经 `camelCase` 抵达的是类名,而类名对每个工具都发出,包括被判据拒绝的那些:工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。暴露窗口恰是两个版本之间新增的那些字符,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部三个读取点」之间显式作出决定——只钉判据会留下类名那条路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。 +其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其首字符或尾字符在两个版本之间被加进 `XID_Start`/`XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽:U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC(在那里未分配;CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs` 以 `invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index c50ba657c1..0535f15d24 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -31,8 +31,8 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * * Python identifiers are not ASCII: `路径` is as legal a field name as `path`, * and rejecting it would degrade the whole enclosing object, dropping every - * field's name, requiredness, and type — which under `mode: 'code'` is the - * model's only source for them. + * field's name, requiredness, and type — information whose only source under + * `mode: 'code'` is this generated text. * * NFKC stability is a second and separate condition, because CPython * normalizes identifiers at compile time while JSON keys are compared as @@ -41,9 +41,11 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * that normalize together would collapse into one declaration. Those names * take the subscript path, which carries their exact bytes. * - * The equivalence to `str.isidentifier()` was measured across 21 samples with - * zero divergence, on Node 22.23.1 against CPython 3.9.6 — the halves the two - * conditions are proxies for, both tested by that run. + * `IDENTIFIER`'s equivalence to `str.isidentifier()` was measured across 21 + * samples with zero divergence, on Node 22.23.1 against CPython 3.9.6. The + * predicate as a whole is deliberately stricter than `isidentifier()`, which + * does not test NFKC stability: `'field'.isidentifier()` is True and this + * returns false. * * Both conditions are evaluated against the ENGINE's Unicode tables, and the * two sides are versioned independently — `\p{XID_Start}`/`\p{XID_Continue}` @@ -62,14 +64,22 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * condition reduces to the same skew, since normalization stability guarantees * an assigned character's normalization never changes afterwards. * - * This predicate is not the only reader of those tables. {@link camelCase} - * reads them too, through its split set and its head test, and its output is - * emitted for EVERY tool — including one this predicate rejected, whose - * `TypedDict` is still declared and named. A tool named `zz-\u{1E4D0}x` never + * This predicate is not the only reader of engine tables. {@link camelCase} + * reads them at three further points — its split set, its head test, and its + * `toUpperCase()` case mapping — and this predicate's verdict gates none of + * them: a class name derived there reaches emitted text whenever any object + * shape in the tool's schema declares a `TypedDict`, including for a tool this + * predicate rejected. A tool named `zz-\u{1E4D0}x` with such parameters never * reaches the skew here (the `-` rejects it outright) yet emits - * `class Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses. Closing the - * exposure therefore covers all three read points, not this predicate alone; - * it needs the target interpreter's version, which the backend reporting + * `class Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses. The case mapping is + * a separate table rather than an XID membership test, and it fails on names + * both conditions above accept: `\u{019B}` is XID_Start and NFKC-stable, so + * this predicate accepts it and `async def \u{019B}` compiles on 3.9.6, but + * Node uppercases it to `\u{A7DC}` — unassigned in that CPython, whose own + * `.upper()` is the identity here — and the declared `class \u{A7DC}Args` + * fails with `invalid non-printable character U+A7DC`. Closing the exposure + * therefore covers all four read points, not this predicate alone; it needs + * the target interpreter's version, which the backend reporting * `language: 'python'` owns and which is unpublished on this base, so the note * records it as that PR's decision. * @@ -237,10 +247,13 @@ function docLines(description: unknown, indent: number): string[] { * normalizing only the un-prefixed part would emit a name CPython compiles to * a different symbol. The second call is idempotent on the un-prefixed arm. * - * The split set and the head test read the engine's Unicode tables, so this - * function carries the same version skew {@link isBareIdentifier} documents, - * by an independent path: a class name derived here is emitted for every tool, - * including one the predicate rejected. + * The split set, the head test, and `toUpperCase()` all read the engine's + * Unicode tables, so this function carries the same version skew + * {@link isBareIdentifier} documents, by paths independent of it: a class name + * derived here reaches emitted text whenever any object shape in the tool's + * schema declares a `TypedDict`, and the predicate's verdict on the tool name + * does not gate that. The case mapping is the one that can fail on a name the + * predicate accepted; the worked example is there. * @param raw - the schema field or tool name to derive from. * @returns a class-name segment safe to emit. */ diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index a5e2f660f2..81ab217d29 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -472,6 +472,27 @@ describe('renderToolsSdkPy', () => { expect(text).not.toContain('async def find') }) + it('derives a class name through the case-mapping table, independently of the bare-name predicate', () => { + // The head capitalization reads a table `isBareIdentifier` never consults, + // so the class-name path can carry a character the predicate cleared. ƛ + // (U+019B) is XID_Start and NFKC-stable, so the method is emitted bare; + // the head maps to Ƛ (U+A7DC), a code point the engine's tables assign and + // an older interpreter's do not. This pins which table produced the name, + // so a change to the mapping step shows up here rather than only in a + // downstream Python parse. + const text = renderToolsSdkPy([ + { + name: 'ƛ', + description: 'Lambda with stroke.', + parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] }, + output: { type: 'string' }, + }, + ]) + expect(text).toContain('async def ƛ(self, args: ꟜArgs) -> str:') + expect(text).toContain('class ꟜArgs(TypedDict):') + expect(text).not.toContain('class ƛArgs') + }) + it('drops a surrogate half rather than cutting a pair when capping an astral class-name base', () => { // Class-name bases are capped by `slice`, which counts UTF-16 code units, // so a boundary landing inside an astral pair would leave a lone high From 0891556cb4f7abb40c4bba7a029304d21b7dc615 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 21:53:32 +0800 Subject: [PATCH 086/190] docs(tools): qualify the three remaining mode-dependent only-claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `mode: 'code'` qualification landed on the module header and `isBareIdentifier` but not on the other three sites the same reviews enumerated. `UNPRINTABLE`'s "only declaration of the tools", the open-object comment's "only signal that extra keys are accepted", and the docstring comment's "only description of what a tool does" are each false under `mode: 'both'`, where the native schemas ship alongside the SDK. Widen the note's predicate-path sentence past head and last position: a character added to `XID_Continue` passes `IDENTIFIER`'s trailing quantifier anywhere after the head, the middle of a name included. Record the ƛ test's table provenance. U+A7DC and the U+019B mapping to it both arrive in Unicode 16.0, and the engines floor sits exactly there: Node 22.19.0 reports Unicode 16.0 (ICU 77.1) and produces the mapping. --- ...6-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 2 +- .../2026-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 16 ++++++++-------- packages/core/tools/tests/py-types.spec.ts | 8 ++++++++ 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 372133ad08..0002098199 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 8115ff4465818a4fa5f6cfb3e35630a9d5e14db3 -2026-07-31-code-mode-language-dispatch.zh.md: c52a5167bf82299d1be00c7095ce075d78d9eb88 +2026-07-31-code-mode-language-dispatch.md: c46f64b704daa5d6cededb6be96f64e825e59a5d +2026-07-31-code-mode-language-dispatch.zh.md: 1851cc18780c1cdf624cb0285669eb0b7b53f14a diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 8115ff4465..c46f64b704 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -43,4 +43,4 @@ The cost is that the Python branch of both tables is unreachable on this base: ` Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both, so one projection reads the runtime twice; both reads are for `run_code`'s own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. -Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set, head test, and `toUpperCase()`. An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name headed or tailed by a character added to `XID_Start`/`XID_Continue` between the two versions. Through `camelCase`'s XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a `TypedDict`, and which the predicate's verdict on the tool name does not gate: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so `async def ƛ` compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own `.upper()` is the identity) and `class ꟜArgs` fails with `invalid non-printable character U+A7DC`. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. +Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set, head test, and `toUpperCase()`. An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name carrying a character added between the two versions — to `XID_Start` at its head, or to `XID_Continue` in any tail position, the middle of a name included. Through `camelCase`'s XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a `TypedDict`, and which the predicate's verdict on the tool name does not gate: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so `async def ƛ` compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own `.upper()` is the identity) and `class ꟜArgs` fails with `invalid non-printable character U+A7DC`. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index c52a5167bf..1851cc1878 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -43,4 +43,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 -其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其首字符或尾字符在两个版本之间被加进 `XID_Start`/`XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽:U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC(在那里未分配;CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs` 以 `invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。 +其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其中带有一个在两个版本之间新增的字符——首位加进 `XID_Start`,或尾部任意位置(含名字中部)加进 `XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽:U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC(在那里未分配;CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs` 以 `invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 0535f15d24..c6cda25215 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -155,8 +155,8 @@ interface RenderState { * CPython rejects source containing a NUL outright * (`SyntaxError: source code string cannot contain null bytes`), whether it * sits in a docstring or in a comment, so one such byte anywhere in a schema - * description would make the whole generated SDK unparseable — the model's only - * declaration of the tools. The rest are legal but invisible; escaping them + * description would make the whole generated SDK unparseable — under + * `mode: 'code'`, the model's only declaration of the tools. The rest are legal but invisible; escaping them * with the same rule keeps the emitted text readable and the treatment uniform. * * The boundary is the category, not per-code-point addressability: `\xNN` @@ -557,9 +557,9 @@ function renderType(schema: unknown, className: string, state: RenderState): str } } // TypedDict syntax cannot express openness, so an open object states it - // in-band: the annotation is advisory either way, and Code Mode omits - // the native schemas, making this line the model's only signal that - // extra keys are accepted. + // in-band: the annotation is advisory either way, and `mode: 'code'` + // omits the native schemas, making this line the model's only signal + // that extra keys are accepted. if (node.additionalProperties !== false) { lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`) } @@ -753,9 +753,9 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { // of that method's body. Emitted before the `async def` it would instead // become the `Tools` class docstring (for the first tool) or a dead // expression (for every later one), leaving every method undocumented — - // and this SDK is the model's only description of what a tool does. A - // docstring is a complete body, so the `...` stub is only for the - // description-less case. + // and under `mode: 'code'` this SDK is the model's only description of + // what a tool does. A docstring is a complete body, so the `...` stub is + // only for the description-less case. const doc = docLines(schema.description, 2) members.push(doc.length > 0 ? `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}:` diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 81ab217d29..b584058765 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -480,6 +480,14 @@ describe('renderToolsSdkPy', () => { // an older interpreter's do not. This pins which table produced the name, // so a change to the mapping step shows up here rather than only in a // downstream Python parse. + // + // Unlike the other Unicode cases in this file, the table row is recent: + // U+A7DC and the U+019B uppercase mapping to it both arrive in Unicode + // 16.0 (`DerivedAge.txt`; CPython 3.12.13's 15.0.0 has neither). The + // engines floor sits exactly there with no margin — Node 22.19.0 reports + // Unicode 16.0 (ICU 77.1) and maps U+019B to U+A7DC, measured — so an + // engine below the floor fails here as a renderer regression whose real + // cause is the table version. const text = renderToolsSdkPy([ { name: 'ƛ', From ab0c2754947955b9d466876af0263c77d796e802 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 22:07:03 +0800 Subject: [PATCH 087/190] style(tools): reflow the UNPRINTABLE paragraph after the qualifier insert The `mode: 'code'` qualifier left a 109-character line where the rest of the block wraps at ~80. --- packages/core/tools/src/py-types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index c6cda25215..9c4aeabafc 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -156,8 +156,9 @@ interface RenderState { * (`SyntaxError: source code string cannot contain null bytes`), whether it * sits in a docstring or in a comment, so one such byte anywhere in a schema * description would make the whole generated SDK unparseable — under - * `mode: 'code'`, the model's only declaration of the tools. The rest are legal but invisible; escaping them - * with the same rule keeps the emitted text readable and the treatment uniform. + * `mode: 'code'`, the model's only declaration of the tools. The rest are + * legal but invisible; escaping them with the same rule keeps the emitted text + * readable and the treatment uniform. * * The boundary is the category, not per-code-point addressability: `\xNN` * addresses U+0000 to U+00FF, so one escape form covers `Cc` exactly. The From c5b09c108f4d19c6ceb05b0302b8a979ab0d51d4 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 22:25:45 +0800 Subject: [PATCH 088/190] docs(tools): record LS/PS as tokenizer non-terminators, with a test Review read `JSON.stringify`'s raw pass-through of U+0085/U+2028/U+2029 as a parse hazard: an LS in a `Literal[...]` value or in a `# tools["..."]` comment would end the physical line and take the SDK block down. Measured on CPython 3.9.6 (Unicode 13.0) and 3.12.13 (15.0): all three are accepted in both a string literal and a `#` comment, value round-tripping, and only LF and CR terminate either. The set is the tokenizer's, not `str.splitlines()`'. Both existing claims were accurate, so nothing changes behaviorally. Name the distinction where it was assumed: `UNPRINTABLE`'s terminator sentence now says which set it means, and `pyScalar`'s raw-pass-through list, previously "DEL and the C1 controls", now also names LS/PS, which are neither. A test pins the raw form for `const` and `enum` so escaping them later cannot land as a silent divergence from the TypeScript flavor. --- packages/core/tools/src/py-types.ts | 16 ++++++++++++---- packages/core/tools/tests/py-types.spec.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 9c4aeabafc..0664fe1443 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -167,7 +167,12 @@ interface RenderState { * U+200B ZWSP, U+200E/U+200F bidi marks, and U+2060 word joiner passed through * would leave a rule that is neither category- nor addressability-shaped. The * whole family is legal in both consumers, since only LF and CR terminate a - * Python string literal or a `#` comment. + * Python string literal or a `#` comment. That set is the tokenizer's, not + * `str.splitlines()`': NEL (U+0085), LS (U+2028), and PS (U+2029) split a + * string at run time but do not end a physical line in source — measured on + * CPython 3.9.6 and 3.12.13, each accepted in both positions with the value + * round-tripping — so they are safe raw wherever they reach emitted text + * unescaped, which for LS and PS is {@link pyScalar}'s `JSON.stringify`. */ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g @@ -408,9 +413,12 @@ function childClassName(base: string, segment: string): string { * That leans on a coincidence worth naming: every escape `JSON.stringify` can * emit (`\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) is also a Python * escape denoting the same character, so the emitted `Literal[...]` both - * parses and decodes back to the value the schema declared. DEL and the C1 - * controls do reach it raw — legal but invisible, byte-for-byte as in the TS - * flavor; escaping them is a both-flavors change. The subscript tool-name + * parses and decodes back to the value the schema declared. DEL, the C1 + * controls, and LS/PS (U+2028/U+2029) do reach it raw — legal but invisible, + * byte-for-byte as in the TS flavor; escaping them is a both-flavors change. + * LS and PS are legal here for the reason {@link UNPRINTABLE} records: they + * are `str.splitlines()` boundaries, not tokenizer line terminators. The + * subscript tool-name * comment quotes its name through its own call to the same `JSON.stringify`, * never through this function, and inherits both halves — escapes and * pass-throughs alike. diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index b584058765..259a1ec7f9 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -67,6 +67,19 @@ describe('jsonSchemaToPy', () => { expect(jsonSchemaToPy({ type: 'string', const: 'ends\\' })).toBe(String.raw`Literal["ends\\"]`) }) + it('passes the paragraph separators through raw, which CPython does not treat as line terminators', () => { + // `JSON.stringify` escapes LF and CR but not LS/PS (U+2028/U+2029), which + // is safe here and not by accident: they are `str.splitlines()` boundaries, + // not tokenizer line terminators, so they end neither a string literal nor + // a `#` comment — measured on CPython 3.9.6 and 3.12.13. Pinning the raw + // form keeps a later "escape them for symmetry with LF" change from + // landing as a silent both-flavors divergence from `ts-types`. + // Escapes below — the two forms denote the same bytes, and neither + // character has a visible width. + expect(jsonSchemaToPy({ type: 'string', const: 'a\u2028b' })).toBe('Literal["a\u2028b"]') + expect(jsonSchemaToPy({ type: 'string', enum: ['a\u2029b'] })).toBe('Literal["a\u2029b"]') + }) + it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded From b869a3b078b1715e55c9967d05d2c04393a78208 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 22:43:42 +0800 Subject: [PATCH 089/190] docs(tools): close the NEL half of the raw pass-through and reflow Four non-blocking review suggestions, all prose plus one assertion. `UNPRINTABLE`'s new sentence named three characters but only two raw-reach points, leaving "and NEL?" open; it now says all three reach text through `pyScalar`, and how the description path handles each. `pyScalar`'s raw-pass-through list already covered NEL under "the C1 controls", and the test now pins it alongside LS and PS, so the docstring's claim has a mechanical check for every character it names. The test title said "paragraph separators" for a pair whose first member is LINE SEPARATOR. Two docstring paragraphs are reflowed to the file's ~80 columns after the earlier inserts left short lines. The note's CPython-floor obligation gains a second axis: the `typing` names the block spells (`TypedDict` 3.8, `NotRequired` 3.11, `A | B` annotations 3.10) are definition-time evaluation floors, not parse floors, so the floor PR does not read "parseable on the supported range" as "executable on it". --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +-- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 29 ++++++++++--------- packages/core/tools/tests/py-types.spec.ts | 21 ++++++++------ 5 files changed, 31 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 0002098199..cbb202fc8b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: c46f64b704daa5d6cededb6be96f64e825e59a5d -2026-07-31-code-mode-language-dispatch.zh.md: 1851cc18780c1cdf624cb0285669eb0b7b53f14a +2026-07-31-code-mode-language-dispatch.md: e7adc5386e101bd02aba525a22070f5cac3d840f +2026-07-31-code-mode-language-dispatch.zh.md: ebfb228aaca7a3aa2a3a7b9f44977f1b0cebe045 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index c46f64b704..e7adc5386e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -43,4 +43,4 @@ The cost is that the Python branch of both tables is unreachable on this base: ` Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both, so one projection reads the runtime twice; both reads are for `run_code`'s own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. -Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set, head test, and `toUpperCase()`. An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name carrying a character added between the two versions — to `XID_Start` at its head, or to `XID_Continue` in any tail position, the middle of a name included. Through `camelCase`'s XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a `TypedDict`, and which the predicate's verdict on the tool name does not gate: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so `async def ƛ` compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own `.upper()` is the identity) and `class ꟜArgs` fails with `invalid non-printable character U+A7DC`. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. +Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set, head test, and `toUpperCase()`. An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name carrying a character added between the two versions — to `XID_Start` at its head, or to `XID_Continue` in any tail position, the middle of a name included. Through `camelCase`'s XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a `TypedDict`, and which the predicate's verdict on the tool name does not gate: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so `async def ƛ` compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own `.upper()` is the identity) and `class ꟜArgs` fails with `invalid non-printable character U+A7DC`. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. A second axis rides along with the floor and is not one of the four: the `typing` names the block spells. `TypedDict` needs 3.8, `NotRequired` 3.11, and a `A | B` annotation evaluates only on 3.10. These are not parse failures — the block parses on any version, which is the standard the `MAX_LIST_NESTING` cap serves — but definition-time evaluation failures, and nothing in the product evaluates this text. Recording them with the read points keeps "parseable on the supported range" from being read as "executable on it". diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 1851cc1878..ebfb228aac 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -43,4 +43,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 -其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其中带有一个在两个版本之间新增的字符——首位加进 `XID_Start`,或尾部任意位置(含名字中部)加进 `XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽:U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC(在那里未分配;CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs` 以 `invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。 +其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其中带有一个在两个版本之间新增的字符——首位加进 `XID_Start`,或尾部任意位置(含名字中部)加进 `XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽:U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC(在那里未分配;CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs` 以 `invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。还有第二条轴随该下限一同确定,且不属于那四个读取点:本块所拼写的 `typing` 名字。`TypedDict` 需要 3.8,`NotRequired` 需要 3.11,而 `A | B` 形式的注解只在 3.10 及以上才可求值。这些不是解析失败——本块在任何版本上都能解析,这正是 `MAX_LIST_NESTING` 上限所服务的标准——而是定义期求值失败,且产品中没有任何东西会求值这段文本。把它们与那四个读取点记在一起,可避免把「在所支持范围上可解析」读成「在其上可执行」。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 0664fe1443..22105d15f6 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -172,7 +172,9 @@ interface RenderState { * string at run time but do not end a physical line in source — measured on * CPython 3.9.6 and 3.12.13, each accepted in both positions with the value * round-tripping — so they are safe raw wherever they reach emitted text - * unescaped, which for LS and PS is {@link pyScalar}'s `JSON.stringify`. + * unescaped, which for all three is {@link pyScalar}'s `JSON.stringify`: the + * `description` path escapes NEL under the class above and folds LS and PS in + * {@link describe}'s `\s+` collapse, both of them being ECMAScript `\s`. */ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g @@ -404,24 +406,23 @@ function childClassName(base: string, segment: string): string { * code point CPython refuses anywhere in source — NUL among the C0 controls, * and the whole D800–DFFF unpaired-surrogate block, escaped under ES2019 * well-formed stringification, which the engines range guarantees — and the - * ones that break this line in particular, - * a bare `"` closing the literal early, a trailing odd backslash eating the - * closing quote, and a bare LF/CR ending it before its terminator. The - * `description` path carries {@link UNPRINTABLE} and {@link LONE_SURROGATE} - * because nothing quotes it, and folds newlines in {@link describe}. + * ones that break this line in particular, a bare `"` closing the literal + * early, a trailing odd backslash eating the closing quote, and a bare LF/CR + * ending it before its terminator. The `description` path carries + * {@link UNPRINTABLE} and {@link LONE_SURROGATE} because nothing quotes it, + * and folds newlines in {@link describe}. * * That leans on a coincidence worth naming: every escape `JSON.stringify` can * emit (`\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) is also a Python * escape denoting the same character, so the emitted `Literal[...]` both * parses and decodes back to the value the schema declared. DEL, the C1 - * controls, and LS/PS (U+2028/U+2029) do reach it raw — legal but invisible, - * byte-for-byte as in the TS flavor; escaping them is a both-flavors change. - * LS and PS are legal here for the reason {@link UNPRINTABLE} records: they - * are `str.splitlines()` boundaries, not tokenizer line terminators. The - * subscript tool-name - * comment quotes its name through its own call to the same `JSON.stringify`, - * never through this function, and inherits both halves — escapes and - * pass-throughs alike. + * controls (NEL among them), and LS/PS (U+2028/U+2029) do reach it raw — + * legal but invisible, byte-for-byte as in the TS flavor; escaping them is a + * both-flavors change. Those last three are legal here for the reason + * {@link UNPRINTABLE} records: they are `str.splitlines()` boundaries, not + * tokenizer line terminators. The subscript tool-name comment quotes its name + * through its own call to the same `JSON.stringify`, never through this + * function, and inherits both halves — escapes and pass-throughs alike. */ function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 259a1ec7f9..8a519d3152 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -67,17 +67,20 @@ describe('jsonSchemaToPy', () => { expect(jsonSchemaToPy({ type: 'string', const: 'ends\\' })).toBe(String.raw`Literal["ends\\"]`) }) - it('passes the paragraph separators through raw, which CPython does not treat as line terminators', () => { - // `JSON.stringify` escapes LF and CR but not LS/PS (U+2028/U+2029), which - // is safe here and not by accident: they are `str.splitlines()` boundaries, - // not tokenizer line terminators, so they end neither a string literal nor - // a `#` comment — measured on CPython 3.9.6 and 3.12.13. Pinning the raw - // form keeps a later "escape them for symmetry with LF" change from - // landing as a silent both-flavors divergence from `ts-types`. - // Escapes below — the two forms denote the same bytes, and neither - // character has a visible width. + it('passes the line and paragraph separators through raw, which CPython does not treat as line terminators', () => { + // `JSON.stringify` escapes LF and CR but not NEL (U+0085), LS (U+2028), or + // PS (U+2029), which is safe here and not by accident: those three are + // `str.splitlines()` boundaries, not tokenizer line terminators, so they + // end neither a string literal nor a `#` comment — measured on CPython + // 3.9.6 and 3.12.13. Pinning the raw form keeps a later "escape them for + // symmetry with LF" change from landing as a silent both-flavors + // divergence from `ts-types`. Escapes below — the two forms denote the + // same bytes, and none of the three has a visible width. expect(jsonSchemaToPy({ type: 'string', const: 'a\u2028b' })).toBe('Literal["a\u2028b"]') expect(jsonSchemaToPy({ type: 'string', enum: ['a\u2029b'] })).toBe('Literal["a\u2029b"]') + // NEL is inside `UNPRINTABLE`'s class, so the description path escapes it; + // this is the one route that carries it raw. + expect(jsonSchemaToPy({ type: 'string', const: 'a\u0085b' })).toBe('Literal["a\u0085b"]') }) it('emits exact digits for a beyond-safe-range integer literal', () => { From 7d957bc7990bcb3696519f9e10cf9605880743bc Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 22:56:32 +0800 Subject: [PATCH 090/190] docs(tools): name both raw routes, complete the evaluation-floor list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's `UNPRINTABLE` sentence said the raw-reach point for all three characters is `pyScalar`'s `JSON.stringify`, and the test comment said that route is the only one. Both are exclusive claims and both are false: the subscript tool-name comment calls `JSON.stringify` itself, and a tool name carrying NEL, LS, or PS always lands there, none of the three being `XID_Continue`. `pyScalar`'s own docstring already recorded that inheritance, so the file contradicted itself. Both sentences now name the two call sites. The note's evaluation axis was introduced as "the `typing` names the block spells", which excludes one of its own members (`A | B` is operator syntax) and omitted PEP 585 builtin generics — `dict[str, Any]` and `list[…]` appear in nearly every render and need 3.9. The axis is now "the names and syntax the block would evaluate at definition time", enumerated 3.8 through 3.11. The test title covered two of the three characters it asserts; NEL is NEXT LINE, neither a line nor a paragraph separator. --- .../2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../feature/2026-07-31-code-mode-language-dispatch.md | 2 +- .../feature/2026-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/py-types.ts | 8 +++++--- packages/core/tools/tests/py-types.spec.ts | 7 ++++--- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index cbb202fc8b..211b854cf5 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: e7adc5386e101bd02aba525a22070f5cac3d840f -2026-07-31-code-mode-language-dispatch.zh.md: ebfb228aaca7a3aa2a3a7b9f44977f1b0cebe045 +2026-07-31-code-mode-language-dispatch.md: 1fbe7ed46885d10e0420004284a40b606cafd521 +2026-07-31-code-mode-language-dispatch.zh.md: c9e0b6f84715db5fd9a0568b4c9a368dd564e315 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index e7adc5386e..1fbe7ed468 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -43,4 +43,4 @@ The cost is that the Python branch of both tables is unreachable on this base: ` Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both, so one projection reads the runtime twice; both reads are for `run_code`'s own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists. -Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set, head test, and `toUpperCase()`. An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name carrying a character added between the two versions — to `XID_Start` at its head, or to `XID_Continue` in any tail position, the middle of a name included. Through `camelCase`'s XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a `TypedDict`, and which the predicate's verdict on the tool name does not gate: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so `async def ƛ` compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own `.upper()` is the identity) and `class ꟜArgs` fails with `invalid non-printable character U+A7DC`. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. A second axis rides along with the floor and is not one of the four: the `typing` names the block spells. `TypedDict` needs 3.8, `NotRequired` 3.11, and a `A | B` annotation evaluates only on 3.10. These are not parse failures — the block parses on any version, which is the standard the `MAX_LIST_NESTING` cap serves — but definition-time evaluation failures, and nothing in the product evaluates this text. Recording them with the read points keeps "parseable on the supported range" from being read as "executable on it". +Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set, head test, and `toUpperCase()`. An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name carrying a character added between the two versions — to `XID_Start` at its head, or to `XID_Continue` in any tail position, the middle of a name included. Through `camelCase`'s XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a `TypedDict`, and which the predicate's verdict on the tool name does not gate: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so `async def ƛ` compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own `.upper()` is the identity) and `class ꟜArgs` fails with `invalid non-printable character U+A7DC`. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. A second axis rides along with the floor and is not one of the four: the names and syntax the block would evaluate at definition time. `TypedDict` needs 3.8, the PEP 585 builtin generics `dict[str, Any]` and `list[…]` need 3.9, an `A | B` annotation 3.10, and `NotRequired` 3.11. These are not parse failures — the block parses on any version, which is the standard the `MAX_LIST_NESTING` cap serves — but definition-time evaluation failures, and nothing in the product evaluates this text. Recording them with the read points keeps "parseable on the supported range" from being read as "executable on it". diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index ebfb228aac..c9e0b6f847 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -43,4 +43,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。 -其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其中带有一个在两个版本之间新增的字符——首位加进 `XID_Start`,或尾部任意位置(含名字中部)加进 `XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽:U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC(在那里未分配;CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs` 以 `invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。还有第二条轴随该下限一同确定,且不属于那四个读取点:本块所拼写的 `typing` 名字。`TypedDict` 需要 3.8,`NotRequired` 需要 3.11,而 `A | B` 形式的注解只在 3.10 及以上才可求值。这些不是解析失败——本块在任何版本上都能解析,这正是 `MAX_LIST_NESTING` 上限所服务的标准——而是定义期求值失败,且产品中没有任何东西会求值这段文本。把它们与那四个读取点记在一起,可避免把「在所支持范围上可解析」读成「在其上可执行」。 +其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其中带有一个在两个版本之间新增的字符——首位加进 `XID_Start`,或尾部任意位置(含名字中部)加进 `XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽:U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC(在那里未分配;CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs` 以 `invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。还有第二条轴随该下限一同确定,且不属于那四个读取点:本块在定义期会被求值的那些名字与语法。`TypedDict` 需要 3.8,PEP 585 的内建泛型 `dict[str, Any]` 与 `list[…]` 需要 3.9,`A | B` 形式的注解需要 3.10,`NotRequired` 需要 3.11。这些不是解析失败——本块在任何版本上都能解析,这正是 `MAX_LIST_NESTING` 上限所服务的标准——而是定义期求值失败,且产品中没有任何东西会求值这段文本。把它们与那四个读取点记在一起,可避免把「在所支持范围上可解析」读成「在其上可执行」。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 22105d15f6..991c85def7 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -172,9 +172,11 @@ interface RenderState { * string at run time but do not end a physical line in source — measured on * CPython 3.9.6 and 3.12.13, each accepted in both positions with the value * round-tripping — so they are safe raw wherever they reach emitted text - * unescaped, which for all three is {@link pyScalar}'s `JSON.stringify`: the - * `description` path escapes NEL under the class above and folds LS and PS in - * {@link describe}'s `\s+` collapse, both of them being ECMAScript `\s`. + * unescaped, which for all three is `JSON.stringify`, at two call sites: + * {@link pyScalar}'s literal path, and the subscript tool-name comment's own + * call, which a name carrying any of them always reaches, none being + * `XID_Continue`. The `description` path escapes NEL under the class above and + * folds LS and PS in {@link describe}'s `\s+` collapse, both being `\s`. */ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 8a519d3152..f800b8b5c3 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -67,7 +67,7 @@ describe('jsonSchemaToPy', () => { expect(jsonSchemaToPy({ type: 'string', const: 'ends\\' })).toBe(String.raw`Literal["ends\\"]`) }) - it('passes the line and paragraph separators through raw, which CPython does not treat as line terminators', () => { + it('passes NEL and the line/paragraph separators through raw, which CPython does not treat as line terminators', () => { // `JSON.stringify` escapes LF and CR but not NEL (U+0085), LS (U+2028), or // PS (U+2029), which is safe here and not by accident: those three are // `str.splitlines()` boundaries, not tokenizer line terminators, so they @@ -78,8 +78,9 @@ describe('jsonSchemaToPy', () => { // same bytes, and none of the three has a visible width. expect(jsonSchemaToPy({ type: 'string', const: 'a\u2028b' })).toBe('Literal["a\u2028b"]') expect(jsonSchemaToPy({ type: 'string', enum: ['a\u2029b'] })).toBe('Literal["a\u2029b"]') - // NEL is inside `UNPRINTABLE`'s class, so the description path escapes it; - // this is the one route that carries it raw. + // NEL is inside `UNPRINTABLE`'s class, so the description path escapes it. + // This is one of the two routes that carry it raw; the other is the + // subscript tool-name comment's own `JSON.stringify` call. expect(jsonSchemaToPy({ type: 'string', const: 'a\u0085b' })).toBe('Literal["a\u0085b"]') }) From 99218ba41d2d89ea576319b28f33c40049a2b74d Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 23:09:06 +0800 Subject: [PATCH 091/190] docs(tools): qualify the last mode-dependent claim, in the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `keeps a non-ASCII field name…` asserted that Code Mode omits the native schemas so nothing else carries a dropped field's name, requiredness, and type. That holds under `mode: 'code'` only; under `both` the native schemas ship alongside the SDK, as the module header says. Earlier rounds swept `py-types.ts` for this family and qualified five sites there; the spec was never in scope, so this is the family's last unqualified member rather than residue from those fixes. --- packages/core/tools/tests/py-types.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index f800b8b5c3..8b42c7dc26 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -419,9 +419,10 @@ describe('renderToolsSdkPy', () => { // `路径` satisfies `xid_start xid_continue*`, so CPython accepts it as an // attribute and as the `TypedDict` key. Rejecting it would degrade the // whole object, dropping every SIBLING field's name, requiredness and type - // too — and Code Mode omits the native schemas, so nothing else carries - // them. The nested class name is derived from the field, so `camelCase` - // has to pass the same characters through instead of splitting on them. + // too — and under `mode: 'code'` the native schemas are omitted, so + // nothing else carries them. The nested class name is from the field, so + // `camelCase` has to pass the same characters through instead of splitting + // on them. const tool: ToolSdkSchema = { name: '搜索', description: 'Unicode identifiers.', From e14bcfb08aae6a7115b46b9b5a02e0eb1cbf7ee9 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 23:25:42 +0800 Subject: [PATCH 092/190] refactor(tools): pin the two language tables to one union, and name python at the seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SDK_RENDERERS` and `RUN_CODE_FLAVORS` had to stay in step by review alone: the `Object.hasOwn` guards catch drift only once a runtime reporting the half-added language exists, which is the one case that cannot arise. Both tables are now `satisfies`-checked against a shared `CodeSdkLanguage` union, so a missing or extra entry fails `typecheck`. The declared `Record<string, …>` type stays, since `CodeRuntime.language` is an unconstrained `string`. The code-runtime seam's own README row and `CodeRuntime.language` JSDoc still named `'typescript'` as the sole well-known value; both now name `'python'` too and say only `'typescript'` has a published backend. --- ...26-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 2 +- .../2026-07-31-code-mode-language-dispatch.zh.md | 2 +- .../code-runtime/code-runtime/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime/README.md | 2 +- packages/code-runtime/code-runtime/README.zh.md | 2 +- packages/code-runtime/code-runtime/src/index.ts | 3 ++- packages/core/tools/src/code-mode.ts | 15 +++++++++++++-- packages/core/tools/src/index.ts | 8 ++++++-- 9 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 211b854cf5..1611e6737a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 1fbe7ed46885d10e0420004284a40b606cafd521 -2026-07-31-code-mode-language-dispatch.zh.md: c9e0b6f84715db5fd9a0568b4c9a368dd564e315 +2026-07-31-code-mode-language-dispatch.md: 292fb104b12fc326261f3716a191c360d69a37d8 +2026-07-31-code-mode-language-dispatch.zh.md: 16be72f9c619bee35295e3e6eec9189fcbc6bb04 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 1fbe7ed468..292fb104b1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -37,7 +37,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe ## Consequences -Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. Which of the two failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. +Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against one `CodeSdkLanguage` union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language exists, which for the half-added language is precisely the case that cannot arise. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index c9e0b6f847..16be72f9c6 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -37,7 +37,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。对两张表都缺席的语言,报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 +新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对同一个 `CodeSdkLanguage` union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后,而对那门只加了一半的语言来说,这恰恰是不可能出现的情形。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml index 8e45c6265b..c0e47dc710 100644 --- a/packages/code-runtime/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/code-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/code-runtime/code-runtime/README.md -README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602 -README.zh.md: 22d0b120d7cea50b578a184b3e40d77707ebc489 +README.md: ec962d7def4bc751151d417fd5a7026038814f33 +README.zh.md: a94ea0feed18f2c7dd99816f072645eebe197e97 diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index c7a2d519e4..ec962d7def 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -11,7 +11,7 @@ This package is the interface third of the capability (the bash trio is the temp | Member | Semantics | |---|---| | `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, invalid completion, output overflow, budget expiry, abort, or substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and a lossless JSON completion becomes `result.value`. | -| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. | +| `language` | Readonly descriptor: the source language `run` expects. `'typescript'` and `'python'` are the well-known values — the two `dsh-tools` presents; only `'typescript'` has a published backend. Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. | | `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. | Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge complete lossless-JSON arguments and resolutions with no seam-level byte cap; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing. diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md index 22d0b120d7..a94ea0feed 100644 --- a/packages/code-runtime/code-runtime/README.zh.md +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -11,7 +11,7 @@ | 成员 | 语义 | |---|---| | `run(request)` | 针对请求的绑定执行一段程序。**所有程序失败结果都通过 resolve 结果中的 error 字段报告**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底终止(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如 dispose(资源释放)后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用,无损 JSON 完成值会成为 `result.value`。 | -| `language` | 只读描述符:`run` 期望的源语言(已知值为 `'typescript'`)。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 | +| `language` | 只读描述符:`run` 期望的源语言。已知值为 `'typescript'` 与 `'python'`——`dsh-tools` 能呈现的两种;其中只有 `'typescript'` 有已发布的后端。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 | | `isolation` | 只读描述符:执行基底(`'worker-thread'`、`'process'`、`'container'`)。供部署与诊断使用,**不构成安全声明**。 | 每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为敌对对等方(任意绑定名称都会成为自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;dispose 会终止进行中的运行,并且在完成前等待其退出。 diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index bd52b9ed29..83c302d13f 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -36,7 +36,8 @@ export abstract class CodeRuntime extends Service { * lowercase identifier. Informational, not gating — a consumer that * generates language-specific presentation (typed SDK stubs, usage * instructions) switches on it and fails loud on a language it cannot - * present. Well-known value: `'typescript'`. + * present. Well-known values: `'typescript'` and `'python'`, the two + * `dsh-tools` presents; only `'typescript'` has a published backend. */ abstract readonly language: string diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 7132ca3646..5ed2c7b4e2 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -100,11 +100,22 @@ const PYTHON_FLAVOR: RunCodeFlavor = { codeDescription: 'The program: the body of an async Python function.', } -/** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per `SDK_RENDERERS` language. */ +/** + * The languages Code Mode ships a presentation for. Both per-language tables — + * {@link RUN_CODE_FLAVORS} here and `SDK_RENDERERS` in {@link ./index.ts} — are + * checked against this union with `satisfies`, so a language added to one and + * not the other fails `typecheck` instead of waiting for a runtime that reports + * it. The tables stay declared `Record<string, …>` because `CodeRuntime.language` + * is an unconstrained `string`: this union pins what the harness ships, while the + * `Object.hasOwn` guards reject what a mounted runtime may report. + */ +export type CodeSdkLanguage = 'typescript' | 'python' + +/** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per {@link CodeSdkLanguage}. */ const RUN_CODE_FLAVORS: Record<string, RunCodeFlavor> = { typescript: TYPESCRIPT_FLAVOR, python: PYTHON_FLAVOR, -} +} satisfies Record<CodeSdkLanguage, RunCodeFlavor> /** * The `description` parameter's model-facing description: language-independent diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 820390228e..23cdc4e085 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -22,6 +22,7 @@ import type { ToolCallView, ToolResultView } from './presentation.ts' import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts' import type { JsonSchemaNode } from './json-schema.ts' import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts' +import type { CodeSdkLanguage } from './code-mode.ts' import { renderToolsSdk } from './ts-types.ts' import type { ToolSdkSchema } from './ts-types.ts' import { renderToolsSdkPy } from './py-types.ts' @@ -33,12 +34,15 @@ import { renderToolsSdkPy } from './py-types.ts' * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a * new backend language is two table entries — an entry here and a * `RUN_CODE_FLAVORS` entry in `code-mode.ts` for its `run_code` schema strings - * — plus the renderer function this table points at. + * — plus the renderer function this table points at. The `satisfies` clause + * pins this table's key set to {@link CodeSdkLanguage}, the same union the + * flavor table is checked against, so adding one entry without the other is a + * typecheck failure. */ const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = { typescript: renderToolsSdk, python: renderToolsSdkPy, -} +} satisfies Record<CodeSdkLanguage, (schemas: ToolSdkSchema[]) => string> export { defineTool, From 9b3a0982c84d3d131c287f4b5308bdebe6879a24 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 23:28:37 +0800 Subject: [PATCH 093/190] docs: regenerate catalogs and graphs for the shifted source anchors --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 12 ++++++------ docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 22b2b85e49..d8ff7ab669 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2103,7 +2103,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:608`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:612`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d05c804887..71c29ec0f8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -835,7 +835,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:187`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -859,7 +859,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:165`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:169`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -881,7 +881,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:140`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:144`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -904,7 +904,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:156`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -925,7 +925,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:129`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:133`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -944,7 +944,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:177`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9ffb38aa4e..a6aa2a5450 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:731`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:735`](../../packages/core/tools/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bee8e96335..fae6ca2de6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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/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:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:140`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:129`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:187`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:133`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:177`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | From 05426906b0272f059eb3ead3621d7039b2b9a9f6 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Wed, 5 Aug 2026 23:44:26 +0800 Subject: [PATCH 094/190] docs(tools): count the union member as an edit, and re-scope the runtime guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'adding a language is two table entries plus its renderer' checklist predates the `CodeSdkLanguage` union and now contradicts the mechanism sentence beside it: following it literally leaves the union untouched, which is exactly the excess-property error that sentence promises. It is three parallel edits, in the note's Decision and Consequences and in the `SDK_RENDERERS` JSDoc. Two guard descriptions still claimed work the compiler took over. The Decision's 'the drift this guards against' now names the `satisfies` pins and leaves the guards their reachable case, a mounted runtime reporting a language neither table knows; `resolveFlavor`'s JSDoc drops 'keeps the table coupled to SDK_RENDERERS' for the same reason. The Consequences said a half-added language 'cannot arise' for the runtime guards — it can, one PR later at the consumer's integration point, and never on this base; the claim is now about timing rather than impossibility. --- .../2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../feature/2026-07-31-code-mode-language-dispatch.md | 4 ++-- .../2026-07-31-code-mode-language-dispatch.zh.md | 4 ++-- packages/core/tools/src/code-mode.ts | 6 ++++-- packages/core/tools/src/index.ts | 10 +++++----- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 1611e6737a..085b288fef 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 292fb104b12fc326261f3716a191c360d69a37d8 -2026-07-31-code-mode-language-dispatch.zh.md: 16be72f9c619bee35295e3e6eec9189fcbc6bb04 +2026-07-31-code-mode-language-dispatch.md: 7347ce99f13f3c40b76b1089a8fee575c92a6df1 +2026-07-31-code-mode-language-dispatch.zh.md: 9ab8701f6b967615166f8fa4e8f071cf17b29a3c diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 292fb104b1..7347ce99f1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -17,7 +17,7 @@ Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at - `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. - `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. -Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is the drift this guards against, not an input that exists — the two tables' key sets are identical today. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is two table entries plus its renderer — no `agent-loop` or registry-structure change. +Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer, with no `agent-loop` or registry-structure change. `code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master. @@ -37,7 +37,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe ## Consequences -Adding a backend language is two table entries — an `SDK_RENDERERS` entry and a `RUN_CODE_FLAVORS` entry — plus the renderer function the former points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against one `CodeSdkLanguage` union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language exists, which for the half-added language is precisely the case that cannot arise. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. +Adding a backend language is three parallel edits — a `CodeSdkLanguage` member, an `SDK_RENDERERS` entry, and a `RUN_CODE_FLAVORS` entry — plus the renderer function the second points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against that one union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language ships: one PR after the drift, at the consumer's integration point rather than where it was introduced, and on this base never, since no second backend exists. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 16be72f9c6..9ab8701f6b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」是这个守卫所防的表漂移,不是已存在的输入——两张表当前键集相同。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 @@ -37,7 +37,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 表项加一个 `RUN_CODE_FLAVORS` 表项——再加前者所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对同一个 `CodeSdkLanguage` union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后,而对那门只加了一半的语言来说,这恰恰是不可能出现的情形。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 +新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR,且触发点在消费方的集成处而非漂移引入处;在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 5ed2c7b4e2..090fe710aa 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -135,8 +135,10 @@ const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION * runtime is mounted — the static schema harvest (doc catalog), which never * reaches a model — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A * mounted runtime whose language has no flavor entry fails loud, exactly as - * `requireCodeRuntime` rejects it at assembly: this keeps the table coupled to - * `SDK_RENDERERS` and never emits a wrong-language schema for a real runtime. + * `requireCodeRuntime` rejects it at assembly. Keeping this table in step with + * `SDK_RENDERERS` is the compiler's job ({@link CodeSdkLanguage}); what this + * guard owns is the runtime-supplied language neither table knows, which never + * yields a wrong-language schema for a real runtime. */ function resolveFlavor(peekRuntime: () => CodeRuntime | undefined): RunCodeFlavor { const runtime = peekRuntime() diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 23cdc4e085..217385de53 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -32,11 +32,11 @@ import { renderToolsSdkPy } from './py-types.ts' * `ctx.codeRuntime.language` in this table when assembling the `tools:sdk` * section under a non-native mode; a runtime whose language is not a key * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a - * new backend language is two table entries — an entry here and a - * `RUN_CODE_FLAVORS` entry in `code-mode.ts` for its `run_code` schema strings - * — plus the renderer function this table points at. The `satisfies` clause - * pins this table's key set to {@link CodeSdkLanguage}, the same union the - * flavor table is checked against, so adding one entry without the other is a + * new backend language is three parallel edits — a {@link CodeSdkLanguage} + * member, an entry here, and a `RUN_CODE_FLAVORS` entry in `code-mode.ts` for + * its `run_code` schema strings — plus the renderer function this table points + * at. The `satisfies` clause pins this table's key set to that union, which + * the flavor table is checked against too, so any of the three left out is a * typecheck failure. */ const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = { From b2c187279954e7c48a0a414332aa7f3203542d80 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 00:07:10 +0800 Subject: [PATCH 095/190] docs(tools): cite per-character Unicode ages and the ungated seam edit --- .../2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 4 ++-- .../2026-07-31-code-mode-language-dispatch.zh.md | 4 ++-- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 12 ++++++------ docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 12 ++++++------ packages/code-runtime/code-runtime/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime/README.md | 2 +- packages/code-runtime/code-runtime/README.zh.md | 2 +- packages/code-runtime/code-runtime/src/index.ts | 2 +- packages/core/tools/src/index.ts | 4 +++- packages/core/tools/src/py-types.ts | 11 +++++++---- packages/core/tools/tests/code-mode.spec.ts | 11 +++++++---- 14 files changed, 42 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 085b288fef..524f6d0046 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 7347ce99f13f3c40b76b1089a8fee575c92a6df1 -2026-07-31-code-mode-language-dispatch.zh.md: 9ab8701f6b967615166f8fa4e8f071cf17b29a3c +2026-07-31-code-mode-language-dispatch.md: b65b9a7c515668af90c14ace2aad4041ff1f8b39 +2026-07-31-code-mode-language-dispatch.zh.md: b4baa3c33b2050e6a9e8479031763cb49b788fcf diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 7347ce99f1..b65b9a7c51 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -17,7 +17,7 @@ Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at - `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. - `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. -Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer, with no `agent-loop` or registry-structure change. +Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the seam's well-known-value list (`dsh-code-runtime`'s README pair and `CodeRuntime.language` JSDoc), which no gate checks, with no `agent-loop` or registry-structure change. `code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master. @@ -37,7 +37,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe ## Consequences -Adding a backend language is three parallel edits — a `CodeSdkLanguage` member, an `SDK_RENDERERS` entry, and a `RUN_CODE_FLAVORS` entry — plus the renderer function the second points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against that one union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language ships: one PR after the drift, at the consumer's integration point rather than where it was introduced, and on this base never, since no second backend exists. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. +Adding a backend language is three parallel edits — a `CodeSdkLanguage` member, an `SDK_RENDERERS` entry, and a `RUN_CODE_FLAVORS` entry — plus the renderer function the second points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against that one union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language ships: one PR after the drift, at the consumer's integration point rather than where it was introduced, and on this base never, since no second backend exists. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. One further edit is outside that check: `dsh-code-runtime`'s README pair and its `CodeRuntime.language` JSDoc list the well-known values, and prose cannot be `satisfies`-checked against a union in a package the seam does not depend on — the interface package must not import its consumer's table. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 9ab8701f6b..b4baa3c33b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及 seam 的已知值清单(`dsh-code-runtime` 的 README 双语对与 `CodeRuntime.language` JSDoc,无任何 gate 检查它),不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 @@ -37,7 +37,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR,且触发点在消费方的集成处而非漂移引入处;在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 +新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR,且触发点在消费方的集成处而非漂移引入处;在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。还有一处编辑落在这条检查之外:`dsh-code-runtime` 的 README 双语对及其 `CodeRuntime.language` JSDoc 列出已知值,而散文无法对一个 seam 并不依赖的包里的 union 做 `satisfies` 校验——接口包不得 import 其消费方的表。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d8ff7ab669..4559c3874c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2103,7 +2103,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:612`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:614`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 71c29ec0f8..6b91f3c068 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -835,7 +835,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:187`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:189`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -859,7 +859,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:169`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:171`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -881,7 +881,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:144`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:146`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -904,7 +904,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:156`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:158`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -925,7 +925,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:133`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:135`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -944,7 +944,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:177`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:179`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a6aa2a5450..ba2ea9fb4d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:735`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:737`](../../packages/core/tools/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fae6ca2de6..d3d28642d8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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/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:187`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:133`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:177`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:171`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:158`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:135`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:179`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml index c0e47dc710..6da4ec0ca9 100644 --- a/packages/code-runtime/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/code-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/code-runtime/code-runtime/README.md -README.md: ec962d7def4bc751151d417fd5a7026038814f33 -README.zh.md: a94ea0feed18f2c7dd99816f072645eebe197e97 +README.md: e9641041af76b60606f999783f29224d8d79c743 +README.zh.md: cc97b6b6cf7c8c5aedb58e40d06ee6dd962ac3b2 diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index ec962d7def..e9641041af 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -11,7 +11,7 @@ This package is the interface third of the capability (the bash trio is the temp | Member | Semantics | |---|---| | `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, invalid completion, output overflow, budget expiry, abort, or substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and a lossless JSON completion becomes `result.value`. | -| `language` | Readonly descriptor: the source language `run` expects. `'typescript'` and `'python'` are the well-known values — the two `dsh-tools` presents; only `'typescript'` has a published backend. Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. | +| `language` | Readonly descriptor: the source language `run` expects. `'typescript'` and `'python'` are the well-known values — those `dsh-tools` presents; only `'typescript'` has a published backend. Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. | | `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. | Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge complete lossless-JSON arguments and resolutions with no seam-level byte cap; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing. diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md index a94ea0feed..cc97b6b6cf 100644 --- a/packages/code-runtime/code-runtime/README.zh.md +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -11,7 +11,7 @@ | 成员 | 语义 | |---|---| | `run(request)` | 针对请求的绑定执行一段程序。**所有程序失败结果都通过 resolve 结果中的 error 字段报告**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底终止(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject(例如 dispose(资源释放)后仍提交运行)。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用,无损 JSON 完成值会成为 `result.value`。 | -| `language` | 只读描述符:`run` 期望的源语言。已知值为 `'typescript'` 与 `'python'`——`dsh-tools` 能呈现的两种;其中只有 `'typescript'` 有已发布的后端。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 | +| `language` | 只读描述符:`run` 期望的源语言。已知值为 `'typescript'` 与 `'python'`——`dsh-tools` 能呈现的那些;其中只有 `'typescript'` 有已发布的后端。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 | | `isolation` | 只读描述符:执行基底(`'worker-thread'`、`'process'`、`'container'`)。供部署与诊断使用,**不构成安全声明**。 | 每个实现都必须遵守以下语义(完整契约见类 JSDoc):绑定调用会桥接完整的无损 JSON 参数与 resolve 值,seam 层不设字节上限;程序被视为敌对对等方(任意绑定名称都会成为自有属性,格式错误的通信绝不能使宿主崩溃);不同运行之间不保留任何状态;dispose 会终止进行中的运行,并且在完成前等待其退出。 diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 83c302d13f..033a5f238e 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -36,7 +36,7 @@ export abstract class CodeRuntime extends Service { * lowercase identifier. Informational, not gating — a consumer that * generates language-specific presentation (typed SDK stubs, usage * instructions) switches on it and fails loud on a language it cannot - * present. Well-known values: `'typescript'` and `'python'`, the two + * present. Well-known values: `'typescript'` and `'python'`, those * `dsh-tools` presents; only `'typescript'` has a published backend. */ abstract readonly language: string diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 217385de53..081b75fd69 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -37,7 +37,9 @@ import { renderToolsSdkPy } from './py-types.ts' * its `run_code` schema strings — plus the renderer function this table points * at. The `satisfies` clause pins this table's key set to that union, which * the flavor table is checked against too, so any of the three left out is a - * typecheck failure. + * typecheck failure. A fourth edit is not checked anywhere: the seam's + * well-known-value list (`dsh-code-runtime`'s README and its + * `CodeRuntime.language` JSDoc) names the languages this table presents. */ const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = { typescript: renderToolsSdk, diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 991c85def7..a79da7b34e 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -52,9 +52,11 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * follow the running engine (Node 22.23.1 reports Unicode 17.0) while CPython * follows its own (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython * older than the engine is the dangerous direction: a character added to - * either property since its tables (U+1C89, U+10570, U+1E290, U+1E4D0 are all - * NFKC-stable and accepted here, and all rejected by that 3.9.6) is emitted - * bare and its tokenizer refuses the character, taking the whole SDK block + * either property since its tables (U+10570 Vithkuqi and U+1E290 Toto, 14.0; + * U+1E4D0 Nag Mundari, 15.0; U+1C89 Cyrillic TJE, 16.0 — ages per + * `DerivedAge.txt`; all four are NFKC-stable and accepted here, and all four + * are `Cn` on that 3.9.6, which rejects them) is emitted bare and its + * tokenizer refuses the character, taking the whole SDK block * down — the same parseability invariant {@link UNPRINTABLE}, * {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING} exist for. Both * properties carry it: a character added only to `XID_Continue` passes the @@ -71,7 +73,8 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * shape in the tool's schema declares a `TypedDict`, including for a tool this * predicate rejected. A tool named `zz-\u{1E4D0}x` with such parameters never * reaches the skew here (the `-` rejects it outright) yet emits - * `class Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses. The case mapping is + * `class Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses — Nag Mundari + * arrived two releases after its tables. The case mapping is * a separate table rather than an XID membership test, and it fails on names * both conditions above accept: `\u{019B}` is XID_Start and NFKC-stable, so * this predicate accepts it and `async def \u{019B}` compiles on 3.9.6, but diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index ee3ef2a91a..30246ccf47 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -391,10 +391,13 @@ describe('mode-aware wire contribution', () => { it('resolves the run_code schema flavor lazily and fails loud on a language absent from the flavor table', async () => { // The flavor getter reads the runtime directly (peekRuntime), so it — not - // requireCodeRuntime — owns the flavor-table guard. A language with no - // flavor entry throws when the schema is projected, keeping - // RUN_CODE_FLAVORS coupled to SDK_RENDERERS. Assembly's requireCodeRuntime - // rejects such a language earlier; this reaches the guard on its own. + // requireCodeRuntime — owns the flavor-table guard. Keeping + // RUN_CODE_FLAVORS in step with SDK_RENDERERS is the compiler's job (both + // are `satisfies`-checked against CodeSdkLanguage), so what the guard + // covers is a mounted runtime naming a language absent from both tables, + // which throws when the schema is projected. Assembly's + // requireCodeRuntime rejects such a language earlier; this reaches the + // guard on its own. const { ctx } = await setup({ mode: 'code', runtime: { language: 'ruby' } }) const definition = ctx.tools.get(RUN_CODE_NAME) // Names the known languages, symmetric with the SDK_RENDERERS guard: this From 24cfe8f77727acca31a6c596f94618d42c2c1604 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 00:22:05 +0800 Subject: [PATCH 096/190] docs(code-runtime): name python in the reference page and complete the ungated-edit list --- .../2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 4 ++-- .../2026-07-31-code-mode-language-dispatch.zh.md | 4 ++-- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 12 ++++++------ docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/code-runtime.i18n.yaml | 4 ++-- docs/core-data-structures/code-runtime.md | 2 +- docs/core-data-structures/code-runtime.zh.md | 2 +- docs/event-producer-consumer.md | 12 ++++++------ packages/core/tools/src/index.ts | 7 ++++--- packages/core/tools/src/py-types.ts | 6 +++--- 12 files changed, 31 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 524f6d0046..9c7db1b701 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: b65b9a7c515668af90c14ace2aad4041ff1f8b39 -2026-07-31-code-mode-language-dispatch.zh.md: b4baa3c33b2050e6a9e8479031763cb49b788fcf +2026-07-31-code-mode-language-dispatch.md: 523f4288066dab126fbccd187eff56f519c7510e +2026-07-31-code-mode-language-dispatch.zh.md: d08be985b849ad3ea11126ae4292e3d343e0b653 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index b65b9a7c51..523f428806 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -17,7 +17,7 @@ Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at - `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. - `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. -Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the seam's well-known-value list (`dsh-code-runtime`'s README pair and `CodeRuntime.language` JSDoc), which no gate checks, with no `agent-loop` or registry-structure change. +Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the seam's well-known-value list (`dsh-code-runtime`'s README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair — no gate checks it), with no `agent-loop` or registry-structure change. `code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master. @@ -37,7 +37,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe ## Consequences -Adding a backend language is three parallel edits — a `CodeSdkLanguage` member, an `SDK_RENDERERS` entry, and a `RUN_CODE_FLAVORS` entry — plus the renderer function the second points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against that one union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language ships: one PR after the drift, at the consumer's integration point rather than where it was introduced, and on this base never, since no second backend exists. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. One further edit is outside that check: `dsh-code-runtime`'s README pair and its `CodeRuntime.language` JSDoc list the well-known values, and prose cannot be `satisfies`-checked against a union in a package the seam does not depend on — the interface package must not import its consumer's table. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. +Adding a backend language is three parallel edits — a `CodeSdkLanguage` member, an `SDK_RENDERERS` entry, and a `RUN_CODE_FLAVORS` entry — plus the renderer function the second points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against that one union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language ships: one PR after the drift, at the consumer's integration point rather than where it was introduced, and on this base never, since no second backend exists. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. One further edit is outside that check: `dsh-code-runtime`'s README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair list the well-known values. Two separate reasons keep that ungated. Prose is not type-checked at all, wherever the union lives. And no type-level pin can stand in for it here: the interface package must not import its consumer's table, and `CodeRuntime.language` stays an unconstrained `string` by design, so moving the union into the seam would not apply it either. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index b4baa3c33b..d08be985b8 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及 seam 的已知值清单(`dsh-code-runtime` 的 README 双语对与 `CodeRuntime.language` JSDoc,无任何 gate 检查它),不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及 seam 的已知值清单(`dsh-code-runtime` 的 README 双语对、它的 `CodeRuntime.language` JSDoc,以及 `docs/core-data-structures/code-runtime.md` 双语对,无任何 gate 检查它),不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 @@ -37,7 +37,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR,且触发点在消费方的集成处而非漂移引入处;在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。还有一处编辑落在这条检查之外:`dsh-code-runtime` 的 README 双语对及其 `CodeRuntime.language` JSDoc 列出已知值,而散文无法对一个 seam 并不依赖的包里的 union 做 `satisfies` 校验——接口包不得 import 其消费方的表。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 +新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR,且触发点在消费方的集成处而非漂移引入处;在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。还有一处编辑落在这条检查之外:`dsh-code-runtime` 的 README 双语对、它的 `CodeRuntime.language` JSDoc,以及 `docs/core-data-structures/code-runtime.md` 双语对列出已知值。让它无 gate 的是两条独立理由。其一,散文根本不受类型检查,union 放在哪里都一样。其二,类型级替代在这里也不可用:接口包不得 import 其消费方的表,而 `CodeRuntime.language` 按设计保持不受约束的 `string`,即便把 union 迁进 seam 也不会作用到它。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4559c3874c..c28343be49 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2103,7 +2103,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:614`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:615`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6b91f3c068..705195e651 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -835,7 +835,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:189`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:190`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -859,7 +859,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:171`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:172`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -881,7 +881,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:146`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:147`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -904,7 +904,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:158`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:159`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -925,7 +925,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:135`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:136`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -944,7 +944,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:179`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:180`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ba2ea9fb4d..dabb3441e1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:737`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:738`](../../packages/core/tools/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index fbdee4c938..686ba7b940 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/code-runtime.md -code-runtime.md: 64de3c45d4f1d1d981daa6c6f074abb667e0aa52 -code-runtime.zh.md: daf07aaf613852a6c4a7b1aff152fcc61052fbca +code-runtime.md: 24127dafbd4a202b6764b55319ec404e77391929 +code-runtime.zh.md: 35f06f2b48bfd3af6ccac6d9a4dd366ea9ec0c92 diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index 64de3c45d4..24127dafbd 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -144,4 +144,4 @@ interface CodeRunFailure { ## The service -`CodeRuntime` (`ctx.codeRuntime`, abstract — defined in [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts)) is `run(request)` plus two readonly descriptors: `language` (what the program must be written in — `'typescript'` is the well-known value; a consumer generating language-specific presentation switches on it and fails loud on one it cannot present) and `isolation` (the execution substrate — `'worker-thread'`, `'process'`, `'container'`; a diagnostic label, **not a security claim**). Implementations must keep runs isolated from each other (no cross-run state) and dispose to quiescence: in-flight runs are terminated and awaited before teardown completes. +`CodeRuntime` (`ctx.codeRuntime`, abstract — defined in [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts)) is `run(request)` plus two readonly descriptors: `language` (what the program must be written in — `'typescript'` and `'python'` are the well-known values, those `dsh-tools` presents, and only `'typescript'` has a published backend; a consumer generating language-specific presentation switches on it and fails loud on one it cannot present) and `isolation` (the execution substrate — `'worker-thread'`, `'process'`, `'container'`; a diagnostic label, **not a security claim**). Implementations must keep runs isolated from each other (no cross-run state) and dispose to quiescence: in-flight runs are terminated and awaited before teardown completes. diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index daf07aaf61..35f06f2b48 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -144,4 +144,4 @@ interface CodeRunFailure { ## 服务 -`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,`'typescript'` 是已知值;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时等待系统完全停稳:teardown 要等到所有进行中的运行均已终止并结算后才完成。 +`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,已知值为 `'typescript'` 与 `'python'`,即 `dsh-tools` 能呈现的那些,其中只有 `'typescript'` 有已发布的后端;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时等待系统完全停稳:teardown 要等到所有进行中的运行均已终止并结算后才完成。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d3d28642d8..f6b0e76f87 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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/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:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:171`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:158`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:135`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:179`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:172`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:147`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:159`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:136`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:180`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 081b75fd69..9c334f668a 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -37,9 +37,10 @@ import { renderToolsSdkPy } from './py-types.ts' * its `run_code` schema strings — plus the renderer function this table points * at. The `satisfies` clause pins this table's key set to that union, which * the flavor table is checked against too, so any of the three left out is a - * typecheck failure. A fourth edit is not checked anywhere: the seam's - * well-known-value list (`dsh-code-runtime`'s README and its - * `CodeRuntime.language` JSDoc) names the languages this table presents. + * typecheck failure. A further edit is not checked anywhere: the seam's + * well-known-value list — `dsh-code-runtime`'s README pair, its + * `CodeRuntime.language` JSDoc, and `docs/core-data-structures/code-runtime.md` + * with its zh pair — names the languages this table presents. */ const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = { typescript: renderToolsSdk, diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index a79da7b34e..1f986f6cf5 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -74,9 +74,9 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * predicate rejected. A tool named `zz-\u{1E4D0}x` with such parameters never * reaches the skew here (the `-` rejects it outright) yet emits * `class Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses — Nag Mundari - * arrived two releases after its tables. The case mapping is - * a separate table rather than an XID membership test, and it fails on names - * both conditions above accept: `\u{019B}` is XID_Start and NFKC-stable, so + * arrived two releases after its tables. The case mapping is a separate table + * rather than an XID membership test, and it fails on names both conditions + * above accept: `\u{019B}` is XID_Start and NFKC-stable, so * this predicate accepts it and `async def \u{019B}` compiles on 3.9.6, but * Node uppercases it to `\u{A7DC}` — unassigned in that CPython, whose own * `.upper()` is the identity here — and the declared `class \u{A7DC}Args` From 670d6511af38df5d346ef6ec22bd79bfc6b69508 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 00:37:51 +0800 Subject: [PATCH 097/190] docs(tools): reflow the identifier-skew comment paragraphs to the 80-column wrap --- packages/core/tools/src/py-types.ts | 46 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 1f986f6cf5..018928b819 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -51,17 +51,16 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * two sides are versioned independently — `\p{XID_Start}`/`\p{XID_Continue}` * follow the running engine (Node 22.23.1 reports Unicode 17.0) while CPython * follows its own (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython - * older than the engine is the dangerous direction: a character added to - * either property since its tables (U+10570 Vithkuqi and U+1E290 Toto, 14.0; - * U+1E4D0 Nag Mundari, 15.0; U+1C89 Cyrillic TJE, 16.0 — ages per - * `DerivedAge.txt`; all four are NFKC-stable and accepted here, and all four - * are `Cn` on that 3.9.6, which rejects them) is emitted bare and its - * tokenizer refuses the character, taking the whole SDK block - * down — the same parseability invariant {@link UNPRINTABLE}, - * {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING} exist for. Both - * properties carry it: a character added only to `XID_Continue` passes the - * trailing `\p{XID_Continue}*` in a tail position and fails the same way. A - * CPython newer than the engine only routes a legal name to the + * older than the engine is the dangerous direction: a character added to either + * property since its tables (U+10570 Vithkuqi and U+1E290 Toto, 14.0; U+1E4D0 + * Nag Mundari, 15.0; U+1C89 Cyrillic TJE, 16.0 — ages per `DerivedAge.txt`; all + * four are NFKC-stable and accepted here, and all four are `Cn` on that 3.9.6, + * which rejects them) is emitted bare and its tokenizer refuses the character, + * taking the whole SDK block down — the same parseability invariant + * {@link UNPRINTABLE}, {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING} + * exist for. Both properties carry it: a character added only to `XID_Continue` + * passes the trailing `\p{XID_Continue}*` in a tail position and fails the same + * way. A CPython newer than the engine only routes a legal name to the * subscript/`dict[str, Any]` path: less readable, still correct. The NFKC * condition reduces to the same skew, since normalization stability guarantees * an assigned character's normalization never changes afterwards. @@ -72,19 +71,18 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * them: a class name derived there reaches emitted text whenever any object * shape in the tool's schema declares a `TypedDict`, including for a tool this * predicate rejected. A tool named `zz-\u{1E4D0}x` with such parameters never - * reaches the skew here (the `-` rejects it outright) yet emits - * `class Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses — Nag Mundari - * arrived two releases after its tables. The case mapping is a separate table - * rather than an XID membership test, and it fails on names both conditions - * above accept: `\u{019B}` is XID_Start and NFKC-stable, so - * this predicate accepts it and `async def \u{019B}` compiles on 3.9.6, but - * Node uppercases it to `\u{A7DC}` — unassigned in that CPython, whose own - * `.upper()` is the identity here — and the declared `class \u{A7DC}Args` - * fails with `invalid non-printable character U+A7DC`. Closing the exposure - * therefore covers all four read points, not this predicate alone; it needs - * the target interpreter's version, which the backend reporting - * `language: 'python'` owns and which is unpublished on this base, so the note - * records it as that PR's decision. + * reaches the skew here (the `-` rejects it outright) yet emits `class + * Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses — Nag Mundari arrived two + * releases after its tables. The case mapping is a separate table rather than + * an XID membership test, and it fails on names both conditions above accept: + * `\u{019B}` is XID_Start and NFKC-stable, so this predicate accepts it and + * `async def \u{019B}` compiles on 3.9.6, but Node uppercases it to `\u{A7DC}` + * — unassigned in that CPython, whose own `.upper()` is the identity here — and + * the declared `class \u{A7DC}Args` fails with `invalid non-printable character + * U+A7DC`. Closing the exposure therefore covers all four read points, not this + * predicate alone; it needs the target interpreter's version, which the backend + * reporting `language: 'python'` owns and which is unpublished on this base, so + * the note records it as that PR's decision. * * The `ts-types` sibling keeps its own ASCII rule rather than sharing this * one: ECMAScript identifiers are a different set (`$`, ZWJ/ZWNJ) and are From e19740e7d0d09c6b3ef4bcab9b24cf035303571d Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 00:51:09 +0800 Subject: [PATCH 098/190] docs(tools): bind wrapped em-dashes, widen the dict degrade note, bound the determinism claim --- packages/core/tools/README.i18n.yaml | 4 ++-- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/py-types.ts | 19 +++++++++++-------- packages/core/tools/tests/py-types.spec.ts | 8 ++++---- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index f5a9234f1e..c1bd91ce2b 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: 20df93e734afb9e7f4280d3aa208af2c8338001c -README.zh.md: a9741673b7283a78223fb9523abef022a79638e4 +README.md: 81cc57983d83fd19468017b217d4db9978f4e228 +README.zh.md: 9f875bd80a03d1d0f78625ee98eeaad9d118f871 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 20df93e734..81cc57983d 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -116,7 +116,7 @@ Returning `undefined` selects generic fallback. Presenters depend only on their Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. -- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute). +- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). - **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. - **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index a9741673b7..9f875bd80a 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -116,7 +116,7 @@ ctx.tools.register(defineTool({ 在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。 -- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,整个对象降级为 `dict[str, Any]`)。 +- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 - **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发契约的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 - **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。 - **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 018928b819..f4bd8af36a 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -76,13 +76,13 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * releases after its tables. The case mapping is a separate table rather than * an XID membership test, and it fails on names both conditions above accept: * `\u{019B}` is XID_Start and NFKC-stable, so this predicate accepts it and - * `async def \u{019B}` compiles on 3.9.6, but Node uppercases it to `\u{A7DC}` - * — unassigned in that CPython, whose own `.upper()` is the identity here — and - * the declared `class \u{A7DC}Args` fails with `invalid non-printable character - * U+A7DC`. Closing the exposure therefore covers all four read points, not this - * predicate alone; it needs the target interpreter's version, which the backend - * reporting `language: 'python'` owns and which is unpublished on this base, so - * the note records it as that PR's decision. + * `async def \u{019B}` compiles on 3.9.6, but Node uppercases it to + * `\u{A7DC}` — unassigned in that CPython, whose own `.upper()` is the identity + * here — and the declared `class \u{A7DC}Args` fails with `invalid + * non-printable character U+A7DC`. Closing the exposure therefore covers all + * four read points, not this predicate alone; it needs the target interpreter's + * version, which the backend reporting `language: 'python'` owns and which is + * unpublished on this base, so the note records it as that PR's decision. * * The `ts-types` sibling keeps its own ASCII rule rather than sharing this * one: ECMAScript identifiers are a different set (`$`, ZWJ/ZWNJ) and are @@ -743,7 +743,10 @@ The available tools:` * Deterministic — tools are emitted in lexicographic name order, and class * declarations precede the protocol in that same order (nested classes before * the parent that references them), so an unchanged tool set produces - * byte-identical text across assemblies. + * byte-identical text across assemblies. The sort is not a total order on + * byte-equal names, so two schemas sharing a name would render in argument + * order; the caller's visible-capability map is keyed by name, so the input + * never carries a duplicate. * @param schemas - the tool schemas plus canonical output schemas to declare * (the caller excludes `run_code` itself). * @returns the complete section text. diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 8b42c7dc26..8004a330e7 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -1115,10 +1115,10 @@ describe('renderToolsSdkPy', () => { it('escapes unpaired surrogates, which make the source impossible to encode', () => { // This is the NUL case, not the invisible-character case: Python source // must be UTF-8-encodable, and `compile()` raises `UnicodeEncodeError: - // surrogates not allowed` for a lone surrogate in a string literal and in - // a `#` comment alike, so one would stop this block — Code Mode's only SDK - // — from parsing. A wire description reaches it: `JSON.parse` on a - // `"\ud800"` escape yields exactly this code point. + // surrogates not allowed` for a lone surrogate in a string literal and in a + // `#` comment alike, so one would stop this block — Code Mode's only SDK — + // from parsing. A wire description reaches it: `JSON.parse` on a `"\ud800"` + // escape yields exactly this code point. const high = renderToolsSdkPy([described('a\ud800b')]) expect(high).not.toContain('\ud800') expect(high).toContain(String.raw`# a\ud800b`) From 665fb987adcfc820a0dd9948523c0ea68e447682 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 01:03:03 +0800 Subject: [PATCH 099/190] docs(tools): mirror the determinism boundary onto the TypeScript renderer --- packages/core/tools/src/ts-types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index 26566d9548..1d33aa3514 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -262,7 +262,10 @@ The available tools:` * Render the full `tools:sdk` prompt section: the fixed usage instructions * plus one `declare const tools` interface covering every given tool. * Deterministic — tools are emitted in lexicographic name order, so an - * unchanged tool set produces byte-identical text across assemblies. + * unchanged tool set produces byte-identical text across assemblies. The sort + * is not a total order on byte-equal names, so two schemas sharing a name + * would render in argument order; the caller's visible-capability map is keyed + * by name, so the input never carries a duplicate. * @param schemas - the tool schemas to declare (the caller excludes * `run_code` itself). * @returns the complete section text. From 21641ae3161f955d04f3db53a17ce9f6c19d83af Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 01:16:09 +0800 Subject: [PATCH 100/190] docs(tools): widen the no-runtime reachable set in resolveFlavor --- .../2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 2 +- .../2026-07-31-code-mode-language-dispatch.zh.md | 2 +- packages/core/tools/src/code-mode.ts | 12 ++++++++---- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 9c7db1b701..91dfc56844 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 523f4288066dab126fbccd187eff56f519c7510e -2026-07-31-code-mode-language-dispatch.zh.md: d08be985b849ad3ea11126ae4292e3d343e0b653 +2026-07-31-code-mode-language-dispatch.md: 1b68c850af809acaccd48f68f0febd6cd8b66e23 +2026-07-31-code-mode-language-dispatch.zh.md: da860859b8f2abe5a5df2d64c32cb3ed5ad73b84 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 523f428806..1b68c850af 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -17,7 +17,7 @@ Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at - `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. - `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. -Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, the doc-catalog schema harvest that never reaches a model) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the seam's well-known-value list (`dsh-code-runtime`'s README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair — no gate checks it), with no `agent-loop` or registry-structure change. +Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, reached by definition readers and `schemas()`, of which the doc-catalog harvest is the only shipped one and none of which feeds a model because assembly passes `requireCodeRuntime` first) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the seam's well-known-value list (`dsh-code-runtime`'s README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair — no gate checks it), with no `agent-loop` or registry-structure change. `code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index d08be985b8..da860859b8 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及 seam 的已知值清单(`dsh-code-runtime` 的 README 双语对、它的 `CodeRuntime.language` JSDoc,以及 `docs/core-data-structures/code-runtime.md` 双语对,无任何 gate 检查它),不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,由直读 definition 的读者与 `schemas()` 到达,其中 doc-catalog 采集是唯一已交付的一个,而它们都不会喂给模型,因为组装路径先过 `requireCodeRuntime`)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及 seam 的已知值清单(`dsh-code-runtime` 的 README 双语对、它的 `CodeRuntime.language` JSDoc,以及 `docs/core-data-structures/code-runtime.md` 双语对,无任何 gate 检查它),不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 090fe710aa..4d87efe449 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -132,8 +132,10 @@ const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION * Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at * schema-emission time so the model-visible `run_code` schema always matches * the SDK section's language. `peekRuntime` returns `undefined` only when no - * runtime is mounted — the static schema harvest (doc catalog), which never - * reaches a model — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A + * runtime is mounted, which reaches this function through definition readers + * and `schemas()` — the doc-catalog harvest is the only shipped one, and none + * of them feeds a model, because `wireSchemas` calls `requireCodeRuntime` + * before projecting — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A * mounted runtime whose language has no flavor entry fails loud, exactly as * `requireCodeRuntime` rejects it at assembly. Keeping this table in step with * `SDK_RENDERERS` is the compiler's job ({@link CodeSdkLanguage}); what this @@ -143,8 +145,10 @@ const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION function resolveFlavor(peekRuntime: () => CodeRuntime | undefined): RunCodeFlavor { const runtime = peekRuntime() if (runtime === undefined) { - // No runtime mounted: reached only by the doc-catalog schema harvest, - // which never feeds a model. Degrade to the TS default. + // No runtime mounted: reached by definition readers and `schemas()`, of + // which the doc-catalog harvest is the only shipped one. None feeds a + // model — `wireSchemas` calls `requireCodeRuntime` before projecting, so + // the assembly path never arrives here. Degrade to the TS default. return TYPESCRIPT_FLAVOR } // Own-property read: a language like `toString`/`constructor` would otherwise From eb3b6357961c26a8246b365a02cfc03a87441a95 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 01:38:26 +0800 Subject: [PATCH 101/190] docs(tools): widen the ungated language-prose list and correct three JSDoc claims --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 4 ++-- ...26-07-31-code-mode-language-dispatch.zh.md | 4 ++-- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 12 +++++----- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 12 +++++----- packages/core/tools/src/code-mode.ts | 13 ++++++----- packages/core/tools/src/index.ts | 7 +++--- packages/core/tools/src/py-types.ts | 22 +++++++++++++------ packages/core/tools/tests/code-mode.spec.ts | 12 +++++----- 11 files changed, 53 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 91dfc56844..66ac99d37c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: 1b68c850af809acaccd48f68f0febd6cd8b66e23 -2026-07-31-code-mode-language-dispatch.zh.md: da860859b8f2abe5a5df2d64c32cb3ed5ad73b84 +2026-07-31-code-mode-language-dispatch.md: 96001252d6494d058a8df9974fb5a0d59e7d7112 +2026-07-31-code-mode-language-dispatch.zh.md: aa7eb2a6b4b9117f1d707b37afcdbe12b814bad2 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index 1b68c850af..96001252d6 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -17,7 +17,7 @@ Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at - `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. - `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. -Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, reached by definition readers and `schemas()`, of which the doc-catalog harvest is the only shipped one and none of which feeds a model because assembly passes `requireCodeRuntime` first) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the seam's well-known-value list (`dsh-code-runtime`'s README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair — no gate checks it), with no `agent-loop` or registry-structure change. +Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, reached by definition readers and `schemas()`, of which the doc-catalog harvest is the only shipped one and none of which feeds a model because assembly passes `requireCodeRuntime` first) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the prose that names the well-known values instead of deriving them (the seam's `dsh-code-runtime` README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair; this package's own README pair and its `Config.mode` JSDoc — no gate checks any of it), with no `agent-loop` or registry-structure change. `code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master. @@ -37,7 +37,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe ## Consequences -Adding a backend language is three parallel edits — a `CodeSdkLanguage` member, an `SDK_RENDERERS` entry, and a `RUN_CODE_FLAVORS` entry — plus the renderer function the second points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against that one union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language ships: one PR after the drift, at the consumer's integration point rather than where it was introduced, and on this base never, since no second backend exists. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. One further edit is outside that check: `dsh-code-runtime`'s README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair list the well-known values. Two separate reasons keep that ungated. Prose is not type-checked at all, wherever the union lives. And no type-level pin can stand in for it here: the interface package must not import its consumer's table, and `CodeRuntime.language` stays an unconstrained `string` by design, so moving the union into the seam would not apply it either. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. +Adding a backend language is three parallel edits — a `CodeSdkLanguage` member, an `SDK_RENDERERS` entry, and a `RUN_CODE_FLAVORS` entry — plus the renderer function the second points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against that one union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language ships: one PR after the drift, at the consumer's integration point rather than where it was introduced, and on this base never, since no second backend exists. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. What stays outside that check is the prose that names the well-known values instead of deriving them: `dsh-code-runtime`'s README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair at the seam, plus this package's own README pair and its `Config.mode` JSDoc. Earlier notes name the values as the state at their own PR and are not on that list. Two separate reasons keep it ungated. Prose is not type-checked at all, wherever the union lives. And no type-level pin can stand in for it here: the interface package must not import its consumer's table, and `CodeRuntime.language` stays an unconstrained `string` by design, so moving the union into the seam would not apply it either. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend. The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index da860859b8..aa7eb2a6b4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd - `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 - `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 -两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,由直读 definition 的读者与 `schemas()` 到达,其中 doc-catalog 采集是唯一已交付的一个,而它们都不会喂给模型,因为组装路径先过 `requireCodeRuntime`)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及 seam 的已知值清单(`dsh-code-runtime` 的 README 双语对、它的 `CodeRuntime.language` JSDoc,以及 `docs/core-data-structures/code-runtime.md` 双语对,无任何 gate 检查它),不动 `agent-loop`,也不动注册表结构。 +两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,由直读 definition 的读者与 `schemas()` 到达,其中 doc-catalog 采集是唯一已交付的一个,而它们都不会喂给模型,因为组装路径先过 `requireCodeRuntime`)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及点名已知值而非从中派生的散文(seam 侧的 `dsh-code-runtime` README 双语对、它的 `CodeRuntime.language` JSDoc 与 `docs/core-data-structures/code-runtime.md` 双语对;本包自己的 README 双语对与它的 `Config.mode` JSDoc,无任何 gate 检查其中任何一处),不动 `agent-loop`,也不动注册表结构。 `code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 @@ -37,7 +37,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## Consequences -新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR,且触发点在消费方的集成处而非漂移引入处;在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。还有一处编辑落在这条检查之外:`dsh-code-runtime` 的 README 双语对、它的 `CodeRuntime.language` JSDoc,以及 `docs/core-data-structures/code-runtime.md` 双语对列出已知值。让它无 gate 的是两条独立理由。其一,散文根本不受类型检查,union 放在哪里都一样。其二,类型级替代在这里也不可用:接口包不得 import 其消费方的表,而 `CodeRuntime.language` 按设计保持不受约束的 `string`,即便把 union 迁进 seam 也不会作用到它。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 +新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR,且触发点在消费方的集成处而非漂移引入处;在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。落在这条检查之外的是点名已知值而非从中派生的散文:seam 侧的 `dsh-code-runtime` README 双语对、它的 `CodeRuntime.language` JSDoc 与 `docs/core-data-structures/code-runtime.md` 双语对,再加本包自己的 README 双语对与它的 `Config.mode` JSDoc。更早的 note 点名这些值时记的是其自身 PR 当时的状态,不在此列。让它无 gate 的是两条独立理由。其一,散文根本不受类型检查,union 放在哪里都一样。其二,类型级替代在这里也不可用:接口包不得 import 其消费方的表,而 `CodeRuntime.language` 按设计保持不受约束的 `string`,即便把 union 迁进 seam 也不会作用到它。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。 代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c28343be49..da76ec8616 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2103,7 +2103,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:615`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:616`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 705195e651..9084c3550c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -835,7 +835,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:190`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:191`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -859,7 +859,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:172`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -881,7 +881,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:147`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -904,7 +904,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:159`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -925,7 +925,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:136`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:137`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -944,7 +944,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:180`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index dabb3441e1..bb898ed8e1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:738`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f6b0e76f87..3de5f8a46a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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/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:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:172`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:147`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:159`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:136`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:180`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:137`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 4d87efe449..4b5cb1fa31 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -72,10 +72,10 @@ interface RunCodeFlavor { } /** - * The TypeScript flavor: the historical default, and the fallback the schema - * harvest degrades to when no runtime is mounted (the doc-catalog generator - * reads `schemas()` without one). A real assembly always resolves a runtime - * first, so the model never sees this fallback outside its own language. + * The TypeScript flavor: the historical default, and the fallback for a schema + * read with no runtime mounted ({@link resolveFlavor} owns which readers reach + * that). A real assembly always resolves a runtime first, so the model never + * sees this fallback outside its own language. */ const TYPESCRIPT_FLAVOR: RunCodeFlavor = { description: @@ -301,8 +301,9 @@ export interface RunCodeBridgeOptions { requireRuntime: () => CodeRuntime /** * Reads `ctx.codeRuntime` without throwing: `undefined` when none is - * mounted. Lets schema emission tell "no runtime" (the doc-catalog harvest, - * degrade to TS) apart from "unknown language" (fail loud). + * mounted. Lets schema emission tell "no runtime" (degrade to TS; the + * readers that reach it are {@link resolveFlavor}'s) apart from "unknown + * language" (fail loud). */ peekRuntime: () => CodeRuntime | undefined /** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */ diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 9c334f668a..b49350c1a3 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -37,10 +37,11 @@ import { renderToolsSdkPy } from './py-types.ts' * its `run_code` schema strings — plus the renderer function this table points * at. The `satisfies` clause pins this table's key set to that union, which * the flavor table is checked against too, so any of the three left out is a - * typecheck failure. A further edit is not checked anywhere: the seam's - * well-known-value list — `dsh-code-runtime`'s README pair, its + * typecheck failure. What no check reaches is the prose that names the values + * instead of deriving them: the seam's `dsh-code-runtime` README pair, its * `CodeRuntime.language` JSDoc, and `docs/core-data-structures/code-runtime.md` - * with its zh pair — names the languages this table presents. + * with its zh pair, plus this package's own README pair and the + * {@link Config.mode} JSDoc. */ const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = { typescript: renderToolsSdk, diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index f4bd8af36a..69aa63fb2d 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -60,7 +60,10 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * {@link UNPRINTABLE}, {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING} * exist for. Both properties carry it: a character added only to `XID_Continue` * passes the trailing `\p{XID_Continue}*` in a tail position and fails the same - * way. A CPython newer than the engine only routes a legal name to the + * way — U+200C ZWNJ and U+200D ZWJ are that case, gaining `XID_Continue` in UCD + * 15.1 and absent from it in 13.0.0, 14.0.0 and 15.0.0, so `a\u{200C}b` is + * emitted bare here while `isidentifier()` is False on 3.9.6 and on 3.12.13 + * (15.0.0). A CPython newer than the engine only routes a legal name to the * subscript/`dict[str, Any]` path: less readable, still correct. The NFKC * condition reduces to the same skew, since normalization stability guarantees * an assigned character's normalization never changes afterwards. @@ -85,8 +88,10 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * unpublished on this base, so the note records it as that PR's decision. * * The `ts-types` sibling keeps its own ASCII rule rather than sharing this - * one: ECMAScript identifiers are a different set (`$`, ZWJ/ZWNJ) and are - * never normalized, so one predicate cannot be correct for both. + * one: ECMAScript identifiers are a different set (`$`) and are never + * normalized, so one predicate cannot be correct for both. ZWJ/ZWNJ are not + * part of that difference — both sets carry them on the engine's tables; what + * separates the two there is the CPython table version above. * @param name - the raw schema field or tool name. * @returns whether the name can be emitted bare. */ @@ -440,10 +445,13 @@ function pyScalar(value: JsonSchemaScalar): string { /** * Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to * the broad type. Deliberately deviates from PEP 586, which restricts `Literal` - * parameters to int/bool/str/bytes/enum/None: a number `const`/`enum` emits a - * float literal (`Literal[1.5]`) a strict checker would reject. Harmless here — - * the stub is advisory prompt text, only required to parse — and keeping the - * exact value communicates the constraint to the model. + * parameters to int/bool/str/bytes/enum/None: a non-integral number + * `const`/`enum` emits a float literal (`Literal[1.5]`) a strict checker would + * reject. An integral one does not deviate — {@link pyScalar} emits int digits, + * including for the beyond-safe-range values it widens through `BigInt`, and + * PEP 586 admits int parameters. Harmless either way — the stub is advisory + * prompt text, only required to parse — and keeping the exact value + * communicates the constraint to the model. */ function renderConstrainedScalar(node: JsonSchemaNode, broad: string, state: RenderState): string { if (node.const !== undefined) { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 30246ccf47..e2c2c8be7e 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -406,11 +406,13 @@ describe('mode-aware wire contribution', () => { .toThrow(/no run_code schema flavor registered for runtime language "ruby" \(known: "typescript", "python"\)/) }) - it('degrades the run_code flavor to TypeScript when no runtime is mounted (doc-catalog schema harvest)', async () => { - // The tool-catalog generator boots the registry under `mode: code` and - // reads run_code's schema WITHOUT a runtime; peekRuntime returns undefined - // there, so the flavor getter degrades to the TS default rather than - // throwing (that harvest never feeds a model). + it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => { + // Any reader of the definition without a mounted runtime lands here; the + // shipped one is the tool-catalog generator, which boots the registry under + // `mode: code` and reads run_code's schema WITHOUT a runtime. peekRuntime + // returns undefined there, so the flavor getter degrades to the TS default + // rather than throwing. None of those readers feeds a model: assembly goes + // through wireSchemas, which requires a runtime first. const { ctx } = await setup({ mode: 'code', runtime: false }) const definition = ctx.tools.get(RUN_CODE_NAME) expect(definition?.description).toContain('Execute a TypeScript program') From 4f8ba6c190a712c992708116a762890035200598 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 02:09:27 +0800 Subject: [PATCH 102/190] test(tools): pin the U+200C tail/head split; qualify the identifier-equivalence measurement The docstring names ZWNJ/ZWJ as a skew instance but nothing checked the predicate's tail-position accept branch; this is its first test, and it also covers camelCase's Tool-prefix branch for a head that is XID_Continue but not XID_Start. The equivalence sentence pinned its evidence to Node 22.23.1 against CPython 3.9.6 without saying the samples sit inside those two versions' shared tables, next to five named characters where that same pair diverges. --- packages/core/tools/src/code-mode.ts | 8 +++--- packages/core/tools/src/py-types.ts | 9 ++++--- packages/core/tools/tests/py-types.spec.ts | 29 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 4b5cb1fa31..aa4a1f027a 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -300,10 +300,10 @@ export interface RunCodeBridgeOptions { /** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */ requireRuntime: () => CodeRuntime /** - * Reads `ctx.codeRuntime` without throwing: `undefined` when none is - * mounted. Lets schema emission tell "no runtime" (degrade to TS; the - * readers that reach it are {@link resolveFlavor}'s) apart from "unknown - * language" (fail loud). + * Reads `ctx.codeRuntime` without throwing: `undefined` when none is mounted. + * Lets schema emission tell "no runtime" (degrade to TS; the readers that + * reach it are {@link resolveFlavor}'s) apart from "unknown language" (fail + * loud). */ peekRuntime: () => CodeRuntime | undefined /** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */ diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 69aa63fb2d..d1358124c3 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -42,10 +42,11 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * take the subscript path, which carries their exact bytes. * * `IDENTIFIER`'s equivalence to `str.isidentifier()` was measured across 21 - * samples with zero divergence, on Node 22.23.1 against CPython 3.9.6. The - * predicate as a whole is deliberately stricter than `isidentifier()`, which - * does not test NFKC stability: `'field'.isidentifier()` is True and this - * returns false. + * samples with zero divergence, on Node 22.23.1 against CPython 3.9.6 — every + * sample sits inside the two versions' shared tables, and the skew characters + * below are exactly where that pair diverges. The predicate as a whole is + * deliberately stricter than `isidentifier()`, which does not test NFKC + * stability: `'field'.isidentifier()` is True and this returns false. * * Both conditions are evaluated against the ENGINE's Unicode tables, and the * two sides are versioned independently — `\p{XID_Start}`/`\p{XID_Continue}` diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 8004a330e7..7a3a573349 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -466,6 +466,35 @@ describe('renderToolsSdkPy', () => { expect(text).not.toContain('field:') }) + it('keeps U+200C in a name tail while rejecting it at a name head, per the two XID properties', () => { + // ZWNJ carries `XID_Continue` and not `XID_Start`, so the predicate splits + // on position: bare in a tail, subscripted at a head. Both verdicts are + // stable across the supported engines — the property arrives in Unicode + // 15.1 and the floor (Node 22.19.0, Unicode 16.0) is past it. + // + // The interpreter side is where this one skews, and it is the same skew the + // docstring's four other characters record, reached in a tail position + // instead of at a head: CPython reads XID_Continue out of the + // `DerivedCoreProperties.txt` of the UCD it was built against (13.0.0 on + // 3.9.6 and 15.0.0 on 3.12.13 both lack the row, and `'a‌b'.isidentifier()` + // is False on both, measured), so the field emitted bare here needs an + // interpreter with 15.1 tables or newer. + const of = (name: string): ToolSdkSchema => ({ + name, + description: `Tool ${name}.`, + parameters: { type: 'object', additionalProperties: false, properties: { 'a‌b': { type: 'string' } } }, + output: { type: 'string' }, + }) + const text = renderToolsSdkPy([of('ping'), of('‌b')]) + expect(text).toContain('async def ping(self, args: PingArgs) -> str:') + expect(text).toContain(' a‌b: NotRequired[str]') + // A head that is XID_Continue but not XID_Start takes the subscript path, + // and `camelCase` prefixes `Tool` to make the class name start legally. + expect(text).toContain('# tools["‌b"](args: Tool‌bArgs) -> str') + expect(text).toContain('class Tool‌bArgs(TypedDict):') + expect(text).not.toContain('async def ‌b') + }) + it('subscripts a tool name that NFKC-normalizes to something else, while declaring a plain Unicode one', () => { // Same split at the tool-name site: `路径` becomes an `async def`, the // ligature name cannot, because `async def find` would define `find`. The From 631d3f930e17703050344ad4cf7b6a0524afbd10 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 02:22:51 +0800 Subject: [PATCH 103/190] test(tools): escape U+200C in the new case and name both carriers of the 15.1 requirement The file's convention is a \uXXXX escape for a character with no visible width (\u0301, \u1100, \u1161, \ud800 are all written that way) and a literal only for a visible one; the new case wrote nine raw ZWNJ. The comment also named only the field as needing 15.1 tables. Two emitted code positions do: the bare field, once in each class, and the Tool\u200CbArgs class name. The subscript comment is not one. --- packages/core/tools/tests/py-types.spec.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 7a3a573349..56c0fc2274 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -476,23 +476,25 @@ describe('renderToolsSdkPy', () => { // docstring's four other characters record, reached in a tail position // instead of at a head: CPython reads XID_Continue out of the // `DerivedCoreProperties.txt` of the UCD it was built against (13.0.0 on - // 3.9.6 and 15.0.0 on 3.12.13 both lack the row, and `'a‌b'.isidentifier()` - // is False on both, measured), so the field emitted bare here needs an - // interpreter with 15.1 tables or newer. + // 3.9.6 and 15.0.0 on 3.12.13 both lack the row, and + // `'a\u200Cb'.isidentifier()` is False on both, measured). Two emitted + // positions then need 15.1 tables or newer: the bare field, once in each + // class, and the `Tool\u200CbArgs` class name. The subscript comment + // quoting the tool name is not one: it is not parsed as an identifier. const of = (name: string): ToolSdkSchema => ({ name, description: `Tool ${name}.`, - parameters: { type: 'object', additionalProperties: false, properties: { 'a‌b': { type: 'string' } } }, + parameters: { type: 'object', additionalProperties: false, properties: { 'a\u200Cb': { type: 'string' } } }, output: { type: 'string' }, }) - const text = renderToolsSdkPy([of('ping'), of('‌b')]) + const text = renderToolsSdkPy([of('ping'), of('\u200Cb')]) expect(text).toContain('async def ping(self, args: PingArgs) -> str:') - expect(text).toContain(' a‌b: NotRequired[str]') + expect(text).toContain(' a\u200Cb: NotRequired[str]') // A head that is XID_Continue but not XID_Start takes the subscript path, // and `camelCase` prefixes `Tool` to make the class name start legally. - expect(text).toContain('# tools["‌b"](args: Tool‌bArgs) -> str') - expect(text).toContain('class Tool‌bArgs(TypedDict):') - expect(text).not.toContain('async def ‌b') + expect(text).toContain('# tools["\u200Cb"](args: Tool\u200CbArgs) -> str') + expect(text).toContain('class Tool\u200CbArgs(TypedDict):') + expect(text).not.toContain('async def \u200Cb') }) it('subscripts a tool name that NFKC-normalizes to something else, while declaring a plain Unicode one', () => { From 0de132b92703b2a18982011f7b0a3486ccdbf477 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 02:33:25 +0800 Subject: [PATCH 104/190] test(tools): drop the quantifier that miscounted its own enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Two emitted positions" was followed by an enumeration counting occurrences — the field twice, the class statement once — so the two halves of the sentence disagreed. The sentence now states what needs the tables without a count. --- packages/core/tools/tests/py-types.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 56c0fc2274..3439cb6a37 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -477,10 +477,10 @@ describe('renderToolsSdkPy', () => { // instead of at a head: CPython reads XID_Continue out of the // `DerivedCoreProperties.txt` of the UCD it was built against (13.0.0 on // 3.9.6 and 15.0.0 on 3.12.13 both lack the row, and - // `'a\u200Cb'.isidentifier()` is False on both, measured). Two emitted - // positions then need 15.1 tables or newer: the bare field, once in each - // class, and the `Tool\u200CbArgs` class name. The subscript comment - // quoting the tool name is not one: it is not parsed as an identifier. + // `'a\u200Cb'.isidentifier()` is False on both, measured). What then needs + // 15.1 tables or newer is the bare field, once in each class, and the + // `Tool\u200CbArgs` class name. The subscript comment quoting the tool name + // is not one of them: it is not parsed as an identifier. const of = (name: string): ToolSdkSchema => ({ name, description: `Tool ${name}.`, From c337215bae3e474675a2d0180af2dbad071fa1c4 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 11:42:31 +0800 Subject: [PATCH 105/190] feat(todo): carry the parallel-active count through ToolRow's summarySuffix Rebuild the todo row's parallel summary on the shared ToolRow that master introduced: planSummary still returns the active name and the remaining count separately, and the row hands the count to a new non-shrinking summarySuffix slot so a narrow row clips the summary text before the count. An error row drops the suffix, whose collapsed summary is the failure line. Re-record the ACP todo-write transcript for the parallel prompt, regenerate the config catalog for the required allowParallelInProgress field, and re-record the bilingual pairing hashes. --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- .../2026-06-29-todo-write-tool.i18n.yaml | 4 +- .../2026-07-23-web-todo-display.i18n.yaml | 4 +- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 6 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 6 +- docs/config-catalog.md | 21 +++++- docs/core-data-structures/session.i18n.yaml | 4 +- .../tests/snapshots/todo-write/session.jsonl | 73 ++++++++++--------- .../client/ui-conversation/README.i18n.yaml | 4 +- .../tests/chat-tool-row.spec.tsx | 14 ++++ packages/todo/tool-todo/README.i18n.yaml | 4 +- 12 files changed, 91 insertions(+), 57 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index e897122368..e292482740 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-07-19-package-invariant-runtime-contracts.md -2026-07-19-package-invariant-runtime-contracts.md: 40d152b2320ac65f9ea7d8732b1a667236d2780a -2026-07-19-package-invariant-runtime-contracts.zh.md: a734b1a3deb739c214d1c4c2565fc697b5e4b89b +2026-07-19-package-invariant-runtime-contracts.md: 86d86f69b606c348e85d1ae654b6b35c4326985a +2026-07-19-package-invariant-runtime-contracts.zh.md: 386a294b577561d08493a3b2925910ee5ba971b4 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index 0875df143d..5ea74ca411 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-29-todo-write-tool.md -2026-06-29-todo-write-tool.md: 288932f641a37c13ea6beeb069ac360c4a8447c1 -2026-06-29-todo-write-tool.zh.md: 2eced2e1670c20726989137d441d1c78df289641 +2026-06-29-todo-write-tool.md: c4cf64b7876bd8b80df80fe6fc27005715f96b5e +2026-06-29-todo-write-tool.zh.md: 1f797c5517df8affd66addecf2a8db43c1da3332 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 54113fb166..2c373a574c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: 7223ff9adbf1fa6dca39c9eb4949b6d3861bdd6d -2026-07-23-web-todo-display.zh.md: 98390c8c2cd95be9d5565b4062d00c1d99215cea +2026-07-23-web-todo-display.md: e89974e08764faaea0b34ac3319d7793dabd9faf +2026-07-23-web-todo-display.zh.md: 99335705359df13b7bdc1d1ecab352dea3f443e8 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index bc033f76b3..65deab6f74 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: a023338704337c5ad2a76a5eaf7ac64a3e949e52 -2026-07-26-todo-parallel-in-progress.zh.md: 163cec20811fcfe756dbf294d152597dade69af6 +2026-07-26-todo-parallel-in-progress.md: 170c0d205a95b0668e8da0997a04849aae2bd59e +2026-07-26-todo-parallel-in-progress.zh.md: 2ff13750bb2f19acccd09ffdd6d4937729e63803 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index a023338704..170c0d205a 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -41,10 +41,10 @@ Lifting the cap makes a list shape reachable that no renderer had ever received, The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists. -`planSummary` returns the name and the count as separate fields rather than one joined string, because the row truncates its summary with `overflow: hidden` / `text-overflow: ellipsis`. A count appended to the task name sits at the far end of the truncatable text, so exactly the narrow viewports and long task names that make the count informative are the ones that clip it away, leaving a parallel plan indistinguishable from a sequential one. The row therefore renders the count in its own `flex: none` span beside the ellipsized text; a pre-joined string could not express that split, and pushing the count in front of the name was rejected because the task name is what the reader is looking for first. +`planSummary` returns the name and the count as separate fields rather than one joined string, because the row truncates its summary with `overflow: hidden` / `text-overflow: ellipsis`. A count appended to the task name sits at the far end of the truncatable text, so exactly the narrow viewports and long task names that make the count informative are the ones that clip it away, leaving a parallel plan indistinguishable from a sequential one. The row therefore hands the count to the shared `ToolRow` as `summarySuffix`, a non-shrinking slot beside the ellipsized summary text; a pre-joined string could not express that split, and pushing the count in front of the name was rejected because the task name is what the reader is looking for first. -Splitting the count into its own span puts it outside the `.summary` rule, so it also has to repeat that rule's `font-size` and `line-height`. The web shell leaves body text at the browser default rather than the row's 14px, so an unstyled span renders visibly larger than the text it sits beside on a 24px row. Inheriting from a shared parent was the alternative; repeating two declarations keeps the split spans independent, which is the property the ellipsis boundary needs. +`summarySuffix` is a slot on `ToolRow` rather than markup owned by the todo row: every toolview renders through that shared component, whose `summary` is a plain ellipsized string with no place for a fragment that must survive the clip. Sitting outside the `.summary` rule, the suffix repeats that rule's `font-size` and `line-height` — the web shell leaves body text at the browser default rather than the row's 14px, so an unstyled span renders visibly larger than the text beside it on a 24px row. An error row drops the suffix, because its collapsed summary is the failure line rather than anything derived from the call args. ## Consequences -A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every `tool-schemas.expected.json` sidecar carrying the todo schema (seven of the eight in the tree). Scenarios composing an identical header share one sidecar through `toolSchemasSource` rather than each keeping a copy, so the count tracks distinct header compositions, not scenarios; a branch changing the tool description still has to refresh whichever sidecars landed after it branched — `pnpm run test:snapshot:refresh` does it keylessly. The web fixture's todo sample now runs two items `in_progress`, so the assembled web transcript replays a parallel plan and would fail again if either surface returned to single-active derivation. +A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every `tool-schemas.expected.json` sidecar carrying the todo schema (seven of the eight in the tree). Scenarios composing an identical header share one sidecar through `toolSchemasSource` rather than each keeping a copy, so the count tracks distinct header compositions, not scenarios; a branch changing the tool description still has to refresh whichever sidecars landed after it branched — `pnpm run test:snapshot:refresh` does it keylessly. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan — `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip, and the ACP `todo-write` scenario records a three-todo plan with two active — and each would fail again if its derivation returned to single-active. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index 163cec2081..2ff13750bb 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -41,10 +41,10 @@ Status: implemented 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。 -`planSummary` 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为工具行用 `overflow: hidden` / `text-overflow: ellipsis` 截断其摘要文本。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此工具行把计数渲染在自己的 `flex: none` span 中,与被省略号截断的文本并列;一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。 +`planSummary` 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为工具行用 `overflow: hidden` / `text-overflow: ellipsis` 截断其摘要文本。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此工具行把计数交给共享的 `ToolRow`,作为 `summarySuffix`——一个紧邻被省略号截断的摘要文本、且不会收缩的槽位;一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。 -把计数拆进独立 span 也意味着它落在 `.summary` 规则之外,因此必须重复该规则的 `font-size` 与 `line-height`。Web 外壳把正文字号留在浏览器默认值而非该行的 14px,所以未加样式的 span 会明显大于同一 24px 行内与之并列的文本。另一个方案是从共同父元素继承;重复这两条声明让被拆开的两个 span 保持互不影响,而这正是省略号边界所需要的性质。 +`summarySuffix` 是 `ToolRow` 上的槽位,而不是 todo 工具行自有的标记:每个 toolview 都经由这个共享组件渲染,而它的 `summary` 是一个会被省略号截断的普通字符串,容不下一个必须挺过截断的片段。该后缀落在 `.summary` 规则之外,因此重复了该规则的 `font-size` 与 `line-height`——Web 外壳把正文字号留在浏览器默认值而非该行的 14px,所以未加样式的 span 会明显大于同一 24px 行内与之并列的文本。错误行会丢弃该后缀,因为它折叠态的摘要是失败行,而非任何由调用 args 推导出的内容。 ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的 `tool-schemas.expected.json` sidecar(树中八个里有七个)。组合出相同 header 的场景通过 `toolSchemasSource` 共用同一份 sidecar,而非各自保留副本,因此这个数量对应的是不同的 header 组合,而不是场景数;改动工具描述的分支仍须刷新它分叉之后落地的那些 sidecar —— `pnpm run test:snapshot:refresh` 可以无 key 完成。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此组装后的 web transcript 回放的是一个并行计划;若任一展示面退回单活跃项推导,它会再次失败。 +现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的 `tool-schemas.expected.json` sidecar(树中八个里有七个)。组合出相同 header 的场景通过 `toolSchemasSource` 共用同一份 sidecar,而非各自保留副本,因此这个数量对应的是不同的 header 组合,而不是场景数;改动工具描述的分支仍须刷新它分叉之后落地的那些 sidecar —— `pnpm run test:snapshot:refresh` 可以无 key 完成。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划——`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划——任一推导退回单活跃项,对应的测试都会失败。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 948999e161..79e35d8f3a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2038,6 +2038,26 @@ export interface Config { Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +## `@deepseek-ai/dsh-tool-todo` + +Requires: `tools` + +```ts config-catalog +/** Model-facing todo tool configuration. */ +export interface Config { + /** + * Required deployment choice for whether several todos may be `in_progress` at once. True suits + * agents that run work concurrently — subagents, background commands, workflow fan-out — and the + * description then instructs the model to mark every actively worked task. False restores the + * single-active discipline: the description asks for exactly one, and a call marking more is + * rejected. + */ + allowParallelInProgress: boolean +} +``` + +Source: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` Requires: `tools` · `web` · `systemPrompt` @@ -2367,7 +2387,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) -- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 565f7275f6..5f645078a2 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: 30f9d7a92f36b0649ec6d61bb3e69a80b125cc73 -session.zh.md: 80762f097bad5f6ab81f3872df5c8b715109241f +session.md: 525703f1494945f23d0e33e06c9cbe7dbabe1c91 +session.zh.md: 6586d7df48aed1701998c01e19b403325f29220f diff --git a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl index 32a6ab449c..acd66fe881 100644 --- a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl @@ -1,36 +1,37 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498772484,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"5ecf5e4b-6a18-447d-9341-48f38afdd12e"}]}} -{"type":"turn/start","seq":1,"time":1785821376741,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821376741,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":3,"time":1783352057657,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498772510,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"5ecf5e4b-6a18-447d-9341-48f38afdd12e"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730425725,"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":"8d4ac045-8016-4cec-8b12-91d9459231e1"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730425725,"data":{"title":"Use the todo_write tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498772511,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730425726,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1783352058426,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":10,"time0":1783352058466,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,17,0,0,0,1,26,1,1,0,0,1,26,0,31,1,25,0,0,0,29,0,0,0,0,91,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," todo","_write"," tool"," to"," record"," a"," plan"," with"," exactly"," three"," todos"," in"," the"," specified"," status","es",","," then"," reply"," with"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":41,"time":1783352058746,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":42,"time0":1783352058747,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,28,0,1,29,1,0,0,0,0,26,0,0,0,0,0,30,1,0,0,0,0,26,1,0,0,0,0,28,0,0,0,0,0,29,0,0,0,0,1,28,0,0,0,1,0,27,1,0,28,62,1],"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","args":["","{","\"","t","odos","\"",": ","[","{\"","content","\":"," \"","read"," the"," code","\","," \"","status","\":"," \"","in","_pro","gress","\"},"," {\"","content","\":"," \"","write"," the"," fix","\","," \"","status","\":"," \"","pending","\"},"," {\"","content","\":"," \"","run"," the"," tests","\","," \"","status","\":"," \"","pending","\"","}]","}"]}} -{"type":"assistant/chunk","seq":96,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."}}}} -{"type":"assistant/chunk","seq":97,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}} -{"type":"assistant/chunk","seq":98,"time":1785498772522,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":99,"time":1785730425738,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":100,"time":1785730425738,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"08141496-5477-4d05-b2c7-414865ea9a17"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99],"surfaceOp":"append"} -{"type":"tool/call","seq":101,"time":1785730425739,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}} -{"type":"todo/write","seq":102,"time":1785730425747,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}} -{"type":"tool/result","seq":103,"time":1785730425748,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fjAnBThbDjxepBtp3hDt3264"},"content":[{"type":"tool-result","toolCallId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"c178ad5b-7c1f-4239-9aa6-20d1c6b00a82"}},"sourceEventSeqs":[101],"surfaceOp":"append"} -{"type":"step/end","seq":104,"time":1785730425748,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":105,"time":1785730425759,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":106,"time":1783352059835,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":107,"time0":1783352059863,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,28,0,1,0,27,0,1,0,0,29,0,0,0,1,0,28,0],"texts":["The"," todos"," have"," been"," written"," successfully","."," Now"," I"," just"," need"," to"," reply"," with"," the"," single"," word"," \"","D","ONE","\"."]}} -{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":130,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":131,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."}}}} -{"type":"assistant/chunk","seq":132,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":133,"time":1785498772545,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":134,"time":1785730425764,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":135,"time":1785730425764,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c4e454ce-14cc-4030-be47-0395ac9f12fb"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134],"surfaceOp":"append"} -{"type":"step/end","seq":136,"time":1785730425764,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":137,"time":1785730425764,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"d9d967e8-0112-471c-a3b5-dfdc171aba61","createdAt":1785987077399,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785987077401,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos for work running in parallel: \"read the code\" (in_progress), \"watch the background build\" (in_progress), \"write the fix\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"befb10e9-f992-4a19-9e1b-333ad7fd72f8"}]}} +{"type":"turn/start","seq":1,"time":1785987077401,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785987077401,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785987077430,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785987077430,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos for work running in parallel: \"read the code\" (in_progress), \"watch the background build\" (in_progress), \"write the fix\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"befb10e9-f992-4a19-9e1b-333ad7fd72f8"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785987077430,"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":"3893b488-4678-4b29-be9f-6365854b0ddc"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785987077430,"data":{"title":"Use the todo_write tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785987077431,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785987077431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1785987079233,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1785987079233,"data":{"turn":1,"step":1,"index":0,"dt":[152,51,2,0,0,61,55,0,1,0,47,53,0,1,0,46,1,0,0,1,45,1,0],"texts":["The"," user"," wants"," me"," to"," use"," todo","_write"," to"," create"," exactly"," three"," todos",","," then"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":34,"time":1785987079909,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":35,"time0":1785987079910,"data":{"turn":1,"step":1,"index":1,"dt":[52,0,1,0,0,0,55,1,0,0,50,1,0,0,0,0,52,0,0,0,0,1,52,1,0,0,0,0,59,0,0,0,0,0,57,0,0,0,0,0,54,0,0,0,0,0,45,1,0,0,0,0,67,0,0,46],"id":"call_00_UHvM5RrwIkjNJ9xh3S735164","name":"todo_write","args":["","{","\"","t","odos","\"",": ","[","{\"","content","\":"," \"","read"," the"," code","\","," \"","status","\":"," \"","in","_pro","gress","\"},"," {\"","content","\":"," \"","watch"," the"," background"," build","\","," \"","status","\":"," \"","in","_pro","gress","\"},"," {\"","content","\":"," \"","write"," the"," fix","\","," \"","status","\":"," \"","pending","\"","}]","}"]}} +{"type":"assistant/chunk","seq":92,"time":1785987080614,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use todo_write to create exactly three todos, then reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":93,"time":1785987080614,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_UHvM5RrwIkjNJ9xh3S735164","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"watch the background build\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}]}"}}}} +{"type":"assistant/chunk","seq":94,"time":1785987080615,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5778,"outputTokens":117,"cacheReadTokens":0,"reasoningTokens":24}}}} +{"type":"assistant/chunk","seq":95,"time":1785987080615,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":96,"time":1785987080618,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use todo_write to create exactly three todos, then reply with \"DONE\" and stop."},{"type":"tool-call","id":"call_00_UHvM5RrwIkjNJ9xh3S735164","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"watch the background build\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"600b618f-2403-4584-b7aa-84b474e7ef08"},"usage":{"inputTokens":5778,"outputTokens":117,"cacheReadTokens":0,"reasoningTokens":24}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95],"surfaceOp":"append"} +{"type":"tool/call","seq":97,"time":1785987080619,"data":{"turn":1,"step":1,"callId":"call_00_UHvM5RrwIkjNJ9xh3S735164","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"watch the background build\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}]}"}} +{"type":"todo/write","seq":98,"time":1785987080632,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"watch the background build","status":"in_progress"},{"content":"write the fix","status":"pending"}]}} +{"type":"tool/result","seq":99,"time":1785987080633,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_UHvM5RrwIkjNJ9xh3S735164"},"content":[{"type":"tool-result","toolCallId":"call_00_UHvM5RrwIkjNJ9xh3S735164","content":[{"type":"text","text":"Updated todo list: 1 pending, 2 in progress, 0 completed."}],"isError":false}],"role":"user","id":"65e181f3-565f-4be4-9ffe-9d59c808f7f8"}},"sourceEventSeqs":[97],"surfaceOp":"append"} +{"type":"step/end","seq":100,"time":1785987080633,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":101,"time":1785987080647,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":102,"time":1785987081239,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":103,"time":1785987081239,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Done"}}} +{"type":"assistant/chunk","seq":104,"time":1785987081391,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":105,"time":1785987081451,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":106,"time":1785987081452,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":107,"time":1785987081452,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":108,"time":1785987081453,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done."}}}} +{"type":"assistant/chunk","seq":109,"time":1785987081453,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":110,"time":1785987081453,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":154,"outputTokens":5,"cacheReadTokens":5760,"reasoningTokens":2}}}} +{"type":"assistant/chunk","seq":111,"time":1785987081453,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":112,"time":1785987081454,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Done."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e4db2f4e-732f-4b58-a44f-5d08b50ce234"},"usage":{"inputTokens":154,"outputTokens":5,"cacheReadTokens":5760,"reasoningTokens":2}},"sourceEventSeqs":[102,103,104,105,106,107,108,109,110,111],"surfaceOp":"append"} +{"type":"step/end","seq":113,"time":1785987081455,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":114,"time":1785987081455,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6ea6e71769..97e4c2b945 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: 4869fa4df929027f031082deb04cc1ab3d18921c -README.zh.md: 5c3091efa11b65f43ea5ea3037a60d0aa1bafbda +README.md: 6a541f0599ef9c46a30df2d5a9d31e3dac96bdb1 +README.zh.md: 842c576f8396fef05ed45be4379ff04f90d448a0 diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 810a810fbb..246c79950d 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -301,6 +301,20 @@ describe('ToolRow', () => { expect(view.getByText('List files')).toBeTruthy() }) + it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => { + const view = render(<ToolRow {...rowProps} summarySuffix="+2" />) + const summary = view.getByText('List files') + const suffix = view.getByText('+2') + // Separate spans: .summary truncates, the suffix must not travel inside it. + expect(summary.contains(suffix)).toBe(false) + view.unmount() + // The failure line replaces the summary wholesale, so the suffix goes with it. + const failed = render( + <ToolRow {...rowProps} state="error" errorSummary="boom" summarySuffix="+2" />, + ) + expect(failed.queryByText('+2')).toBeNull() + }) + it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => { const open = vi.fn() const view = render( diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index e0a1a5ea25..20b8dffbbf 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/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/todo/tool-todo/README.md -README.md: 456d4a08d88b145d574362ffa0874faef9167b22 -README.zh.md: ec37682773e50c3f153525f6c2b6b6cce583144f +README.md: 914e89a000e4bb87ebd7844f05db3809c6726528 +README.zh.md: c88dbf976fa5110028fcc964ab9aa8efcc3244d3 From baed704fc26e392aee67fe1645a130ecdd6d82ef Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 11:46:22 +0800 Subject: [PATCH 106/190] test(todo): follow master's Agent shape and load-time config failure --- .../ui-conversation/tests/todo-panel.spec.tsx | 2 +- .../tests/loader-composition.spec.ts | 33 +++++++------------ 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 7bcc8c4071..82f71517f1 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -132,7 +132,7 @@ describe('TodoPanel', () => { expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3) expect(screen.getByText('跑后台构建')).toBeTruthy() expect(screen.getByText('读源码')).toBeTruthy() - expect(screen.getByText('1 已完成 · 3 进行中 · 1 待处理')).toBeTruthy() + expect(screen.getByText('1 已完成 · 3 进行中 · 1 待处理')).toBeTruthy() }) it('an all-completed list collapses the summary to the done count alone', () => { diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 58e5434acf..572254e348 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -6,12 +6,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context, FiberState } from 'cordis' +import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -30,9 +30,13 @@ afterEach(async () => { function agent(ctx: Context): Agent { const scope = ctx.plugin(() => {}) const id = SessionId('todo-loader-agent') + const session = Session.create(id) const value: Agent = { - id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', ctx: scope.ctx, + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value @@ -126,23 +130,10 @@ describe('tool-todo real Loader composition through cordis.yml', () => { it.each([ { label: 'is omitted', configLines: [], failure: '$.allowParallelInProgress missing required value' }, - { label: 'is not boolean', configLines: [' allowParallelInProgress: "no"'], failure: '$.allowParallelInProgress' }, + { label: 'is not boolean', configLines: [' allowParallelInProgress: "no"'], failure: '$.allowParallelInProgress expected boolean' }, ])('fails loading when allowParallelInProgress $label', async ({ configLines, failure }) => { - // loader.await() is all-settled; configuration failure leaves a FAILED - // entry and escapes as a late rejection for the host boot to report. - const rejections: unknown[] = [] - const onUnhandled = (err: unknown): void => { rejections.push(err) } - process.on('unhandledRejection', onUnhandled) - try { - const ctx = await boot(configLines) - const entry = [...ctx.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-tool-todo') - expect(entry?.fiber?.state).toBe(FiberState.FAILED) - for (let i = 0; i < 100 && rejections.length === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) - } - expect(rejections.map(String).join('\n')).toContain(failure) - } finally { - process.off('unhandledRejection', onUnhandled) - } + // The policy is self-contained, so misconfiguration fails at load: the + // entry's apply rejects and boot never reaches a running tool. + await expect(boot(configLines)).rejects.toThrow(failure) }, 30_000) }) From 3888f367b1efe7f8dcab3a21d8c88e0331cf4d87 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 11:55:49 +0800 Subject: [PATCH 107/190] test(todo): refresh snapshot sidecars that landed after this branch The subagent-* scenarios pin their child tool schemas through their own tool-schemas.1.expected.json, and the headless fixtures embed the request header verbatim; all seven still carried the single-in_progress description. --- .../snapshots/subagent-continuable/tool-schemas.1.expected.json | 2 +- .../snapshots/subagent-list-agents/tool-schemas.1.expected.json | 2 +- .../snapshots/subagent-report/tool-schemas.1.expected.json | 2 +- .../tests/snapshots/advanced-toolchain/session.1.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.2.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.jsonl | 2 +- examples/headless-agent/tests/snapshots/pty-tools/session.jsonl | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json index 273f70e766..519e8eac8c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json @@ -324,7 +324,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json index 273f70e766..519e8eac8c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json @@ -324,7 +324,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { diff --git a/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json index 273f70e766..519e8eac8c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json @@ -324,7 +324,7 @@ }, { "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).", + "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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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": { 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 10c380057d..ff58bd3a43 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -6,7 +6,7 @@ {"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730501506,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498583897,"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> 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<string, JsonValue>;\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<string, JsonValue>;\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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<ToolOutputMap[K]>;\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> 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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\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 <json-value>`)."},"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":7,"time":1785498583897,"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> 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<string, JsonValue>;\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<string, JsonValue>;\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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\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<string, JsonValue>;\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<ToolOutputMap[K]>;\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> 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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\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 <json-value>`)."},"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":8,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"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 d514d19b96..02c779e8f4 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -6,7 +6,7 @@ {"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730501645,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498584067,"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> 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<string, JsonValue>;\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<string, JsonValue>;\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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<ToolOutputMap[K]>;\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> 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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\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 <json-value>`)."},"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":7,"time":1785498584067,"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> 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<string, JsonValue>;\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<string, JsonValue>;\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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\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<string, JsonValue>;\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<ToolOutputMap[K]>;\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> 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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\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 <json-value>`)."},"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":8,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"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 fe5fa4dc8d..a168db4b8b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -5,7 +5,7 @@ {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498583779,"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":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"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> 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<string, JsonValue>;\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<string, JsonValue>;\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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<ToolOutputMap[K]>;\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> 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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\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 <json-value>`)."},"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":6,"time":1785498583782,"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> 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<string, JsonValue>;\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<string, JsonValue>;\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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\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<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\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<string, JsonValue>;\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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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<string, JsonValue>;\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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\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<string, JsonValue>)[];\n } & Record<string, JsonValue>;\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<string, JsonValue>;\n } & Record<string, JsonValue>;\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<string, JsonValue>;\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<ToolOutputMap[K]>;\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> 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:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned 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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\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 <json-value>`)."},"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":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"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 67dbe5883b..76f6b44e0d 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -6,7 +6,7 @@ {"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785730504659,"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":"a5ae9c04-0652-436f-9b5a-437a3a6ed235"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730504659,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498587438,"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> 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\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 <json-value>`)."},"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":7,"time":1785498587438,"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> 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. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\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 <json-value>`)."},"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":8,"time":1785730504660,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"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\"}"}}} From af652c949f24a0920230e8a7455878f416106b75 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 12:07:28 +0800 Subject: [PATCH 108/190] fix(web): recover provider credential lifecycle --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 4 +- .../2026-07-30-web-config-plane.zh.md | 4 +- ...06-provider-credential-lifecycle.i18n.yaml | 6 + ...026-08-06-provider-credential-lifecycle.md | 27 +++ ...-08-06-provider-credential-lifecycle.zh.md | 27 +++ apps/web/tests/models-settings.e2e.ts | 56 ++++-- .../models-settings/configured.expected.md | 4 +- .../models-settings/delete.expected.md | 8 +- .../models-settings/empty.expected.md | 2 +- docs/config-catalog.md | 5 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 6 +- packages/client/ui-models/README.zh.md | 6 +- .../ui-models/src/client/ModelsSection.tsx | 90 ++++++--- .../ui-models/src/client/ProviderEditor.tsx | 48 +++-- .../client/ui-models/src/client/locales.ts | 24 ++- packages/client/ui-models/src/client/store.ts | 12 -- packages/client/ui-models/tests/apply.spec.ts | 6 +- .../ui-models/tests/components.spec.tsx | 178 ++++++++++++++---- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/index.ts | 8 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 7 + 25 files changed, 400 insertions(+), 144 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 647e4649d0..8ec7ff129e 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 5225460be1d66b85a05ff2fd5ae2826b0e6c41d7 -2026-07-30-web-config-plane.zh.md: 53a21ddf31640d963c413e1793276de694547311 +2026-07-30-web-config-plane.md: 11554077d1848dcdf59b896dd9c29a39fd2f55d4 +2026-07-30-web-config-plane.zh.md: 527c2de8155a56789358b801f9c374e16c81931b diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 5225460be1..11554077d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -22,7 +22,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation only when a key is entered), so `settings.yaml` never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. The partial-commit and credential-ownership rationale lives in the [provider credential lifecycle note](../bug-fix/2026-08-06-provider-credential-lifecycle.md). ## Alternatives considered @@ -36,4 +36,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and identified delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves both profile and key intact, then confirmation removes both the profile and its identified managed credential. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models) and a page address for live routes that never declared configurability. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 53a21ddf31..527c2de815 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -22,7 +22,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle`、`--dsw-alias-text-tertiary` 和 `--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`(仅在输入密钥时,pi-ai profile 才会记录该派生),因此 `settings.yaml` 从不携带密钥值;留空 pi-ai 密钥会具化一个不带引用的 profile,并保留提供方原生认证。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化确认对话框,其行操作、标题、说明和最终操作都会点名同一个提供方;确认后会先清除与派生目标精确匹配且已配置、可写的凭据,再删除 profile,自定义目标、环境目标和无法识别的目标则保持不变。两个阶段都具备幂等性,部分失败会留在对话框中供重试。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。部分提交与凭据所有权的理由记录在[提供方凭据生命周期 note](../bug-fix/2026-08-06-provider-credential-lifecycle.md)中。 ## 曾考虑的替代方案 @@ -36,4 +36,4 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与已点名目标的删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明,取消会保留 profile 和密钥,随后的确认会同时删除 profile 及其已识别的受管凭据。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)和为从未声明可配置性的存活路由提供页面地址。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml new file mode 100644 index 0000000000..11ba2e0744 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md +2026-08-06-provider-credential-lifecycle.md: 6965d573af6989dffd7b6066fd8b3e50872a6a25 +2026-08-06-provider-credential-lifecycle.zh.md: de6f76d0725e954e27ec99062832fe40c36fcfe9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md new file mode 100644 index 0000000000..6965d573af --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md @@ -0,0 +1,27 @@ +# Agent Note: Recoverable provider credential lifecycle + +Status: implemented + +English | [中文](2026-08-06-provider-credential-lifecycle.zh.md) + +## Problem + +The Models editor spans independent settings and credential RPC domains. It previously committed provider settings before storing the API key but kept the revision and original subtree from when the card opened. If the credential write failed, retry replayed the already-committed settings mutation with a stale revision and produced a conflict, leaving the user unable to complete the second stage from the same card. A blank pi-ai key also wrote the derived `apiKeyEnv` without a credential, which prevented pi-ai from using provider-native discovery. At deletion, the inverse leak remained: the profile disappeared but its page-stored key stayed in `.env` and silently became active when the provider was added again. Generic row actions and confirmation copy did not identify which provider would be changed. + +## Decision + +Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery. + +Deletion removes a credential only when the joined row identifies the exact `<ROUTE>_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. + +## Alternatives considered + +**Add a cross-domain transaction RPC.** Settings and credentials have separate owning services and durable stores; introducing a new host transaction would broaden the public wire and still require compensation for provider-specific persistence failures. The UI checkpoint makes the current ordered stages recoverable without adding a fourth configuration contract. + +**Delete every credential reference named by a removed profile.** A custom reference can be shared, externally managed, or intentionally survive profile churn. Exact equality with this page's derived target plus configured+writable state is the narrow evidence available to the page; anything weaker risks deleting a credential it does not own. + +**Remove settings first and compensate by recreating the profile.** The browser holds only a redacted subtree and cannot faithfully reconstruct stored literal secrets or concurrent edits. Credential-first deletion leaves the authoritative profile visible on partial failure and makes retry safe without synthesizing configuration. + +## Consequences + +The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, normalized literals, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both `settings.yaml` profile and `.env` credential. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md). diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md new file mode 100644 index 0000000000..de6f76d072 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 可恢复的提供方凭据生命周期 + +Status: implemented + +[English](2026-08-06-provider-credential-lifecycle.md) | 中文 + +## 问题 + +Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它先提交提供方 settings,再存储 API 密钥,却一直保留卡片打开时的 revision 和原始子树。如果凭据写入失败,重试会用陈旧 revision 重放已提交的 settings 变更,并产生冲突,导致用户无法从同一张卡片完成第二个阶段。空的 pi-ai 密钥还会写入派生的 `apiKeyEnv`,却不写入凭据,从而阻止 pi-ai 使用提供方原生凭据发现。删除时则存在相反的残留问题:profile 消失了,页面存储的密钥却保留在 `.env` 中,并在重新添加提供方时静默地恢复作用。笼统的行操作与确认文案也没有标明要更改哪个提供方。 + +## 决策 + +提供方保存仍在现有 wire 领域上按先 settings、后凭据的两阶段顺序执行,但卡片会把成功的 settings 响应视为提交检查点。它会在尝试 `credentials.set` 之前,用返回的脱敏 descriptor 替换比较基准子树与预期 revision;如果第二阶段失败,草稿密钥与卡片会继续显示,重试不会产生 settings op,只会再次写入凭据。首次 settings 提交之前发生的真实并发变更仍会以 `settings-conflict` 失败。UI 与 DeepSeek 直连 resolver 边界均会去除所输密钥的首尾空白,且只有标准化密钥非空时,pi-ai 才会记录派生引用;留空密钥会具化一个空的、不带引用的 profile,以便使用提供方原生凭据发现。 + +只有当联接所得的行识别出该页面派生的精确 `<ROUTE>_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。 + +## 曾考虑的替代方案 + +**添加跨领域事务 RPC。**settings 与凭据分属不同的主管服务与持久存储;引入新的 Host 事务会扩大公开 wire 面,而且仍需要补偿提供方特定的持久化失败。UI 检查点让当前的有序阶段变得可恢复,无需添加第四项配置契约。 + +**删除被移除 profile 所指定的每一个凭据引用。**自定义引用可能被共享、由外部管理,或有意在 profile 反复增删时存留。与该页面派生目标精确相等,再加上已配置且可写的状态,是页面所能获得的最小范围证据;比这更弱的判定都有可能删除不属于它的凭据。 + +**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建已存的字面机密或并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。 + +## 后果 + +Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1d9117dc85..36892f2071 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -1,15 +1,17 @@ // Web e2e scenario: the Models settings page end to end through the real -// wire — the add card offers the dormant pi-ai catalog, typing an API key +// wire — the add card offers the dormant pi-ai catalog, a blank key saves a +// reference-free profile for provider-native auth, and typing an API key later // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) -// while the settings document records only that reference; the saved row -// appears after the route topology invalidation without presenting liveness -// as provider status. The customized-settings fold writes the curated +// while the settings document records only that reference. Each saved row +// appears after route topology invalidation without presenting liveness as +// provider status. The customized-settings fold writes the curated // reasoning field as a merge patch. Zero model calls: configuration is pure // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can // never shadow the derived reference. Removing that row is guarded by the -// localized provider-confirmation dialog before the unset reaches the wire. +// localized, identified provider-confirmation dialog before the credential +// and settings unsets reach the wire. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -75,29 +77,43 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE) }, 60_000) - it('stores the key under the derived reference and the route registers live', async () => { + it('saves a blank key as a reference-free provider-native profile', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth')) + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + const row = dialog.getByText('minimax-cn', { exact: true }).first() + await row.waitFor({ timeout: 10_000 }) + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('minimax-cn: {}') + expect(document).not.toContain('MINIMAX_CN_API_KEY') + }, 60_000) + + it('stores the key under the derived reference and keeps the route live', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the // key value lands in the harness home's .env, the dormant route // registers, and the topology frame invalidates the page into the row. - const row = dialog.getByText('minimax-cn', { exact: true }).first() - await row.waitFor({ timeout: 10_000 }) + await expect.poll(async () => dialog.getByLabel('API 密钥').count(), { timeout: 10_000 }).toBe(0) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') expect(document).not.toContain('sk-e2e-minimax') - const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8') - expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + const credentialFile = join(scaffold.harnessHome, '.env') + await expect.poll( + async () => readFile(credentialFile, 'utf8').catch(() => ''), + { timeout: 10_000 }, + ).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') expect(await page.content()).not.toContain('sk-e2e-minimax') }, 60_000) it('applies a customized-settings field as a merge patch', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized')) const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.getByRole('button', { name: '编辑' }).click() + await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() await dialog.getByText('自定义设置').click() const effort = dialog.getByLabel('推理强度') await effort.waitFor({ timeout: 10_000 }) @@ -114,32 +130,32 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('confirms provider deletion before removing its settings profile', async () => { + it('confirms an identified provider deletion before removing its profile and key', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) - await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() - const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' }) + await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click() + const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' }) await deleteDialog.waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria( page, - '[role="dialog"][aria-label="删除模型提供方?"]', + '[role="dialog"][aria-label="删除 minimax-cn?"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE) await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:') - await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() - await page.getByRole('dialog', { name: '删除模型提供方?' }) - .getByRole('button', { name: '删除提供方', exact: true }).click() + await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click() + await page.getByRole('dialog', { name: '删除 minimax-cn?' }) + .getByRole('button', { name: '删除 minimax-cn', exact: true }).click() await expect.poll( async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 10_000 }, ).not.toContain('minimax-cn:') expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8')) - .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + .not.toContain('MINIMAX_CN_API_KEY') await expect.poll( - async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(), + async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(), { timeout: 10_000 }, ).toBe(0) await page.keyboard.press('Escape') diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 2ff2ae3d6f..2c885817f1 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -16,8 +16,8 @@ - list: - listitem: - text: minimax-cn - - button "编辑" - - button "删除" + - button "编辑 minimax-cn": 编辑 + - button "删除 minimax-cn": 删除 - button "添加提供方": - img - text: 添加提供方 diff --git a/apps/web/tests/snapshots/models-settings/delete.expected.md b/apps/web/tests/snapshots/models-settings/delete.expected.md index afb0cb5fd2..5757ca52ca 100644 --- a/apps/web/tests/snapshots/models-settings/delete.expected.md +++ b/apps/web/tests/snapshots/models-settings/delete.expected.md @@ -1,7 +1,7 @@ -- dialog "删除模型提供方?": - - heading "删除模型提供方?" [level=2] +- dialog "删除 minimax-cn?": + - heading "删除 minimax-cn?" [level=2] - button "关闭": - img - - paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。 + - paragraph: 删除 minimax-cn 会移除其配置和存储的 API 密钥。 - button "取消" - - button "删除提供方" + - button "删除 minimax-cn" diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 161b472e57..ab0a25b780 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -55,7 +55,7 @@ - option "zai-coding-cn" - text: API 密钥 - textbox "API 密钥": - - /placeholder: 输入 API 密钥 + - /placeholder: 输入 API 密钥,或留空使用环境认证 - group: 自定义设置 - button "取消" - button "保存" diff --git a/docs/config-catalog.md b/docs/config-catalog.md index abde0ea3b0..5542fae949 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -642,7 +642,10 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Trimmed literal API key; whitespace-only is absent. Prefer + * {@link apiKeyEnv} to keep secrets out of configuration files. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 2e4cf00248..b34caf8138 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: c578ecfc9163245e8666cb6d2d327efdaccccf89 -README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957 +README.md: 6ae0dd9d43c19f2a4350386104cf328d4d4a65d3 +README.zh.md: 77e2dcfb98ac3ac12a5ecb6975487b8159178937 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index c578ecfc91..6ae0dd9d43 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,11 +4,11 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience @@ -21,5 +21,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. -- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred. +- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 40da5b52f6..77e2dcfb98 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,11 +4,11 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 @@ -21,5 +21,5 @@ ## 已知限制与暂缓事项 - **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 -- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 +- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index b170df1fa0..54b0db3c38 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -14,7 +14,7 @@ import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' -import { messageOf } from './store.ts' +import { deriveKeyRef, messageOf } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' import { ProviderEditor } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -38,42 +38,53 @@ export interface ModelsSectionInjected { */ export type ModelsSectionProps = Partial<ModelsSectionInjected> -/** The editor target: an existing row or a dormant directory entry. */ -interface EditorTarget { +/** Provider identity shared by row actions and confirmation copy. */ +export interface ProviderIdentity { + /** Stable provider route id. */ provider: string + /** Human-facing provider name. */ displayName: string +} + +/** One existing row or dormant directory entry addressed by an editor action. */ +interface EditorTarget extends ProviderIdentity { settingsNs: string settingsPath: readonly string[] + /** Writable credential identified under this page's conventional reference. */ + credentialRef?: string } /** - * Remove one user-added provider profile by unsetting its path in the stored - * user section, then reload. The removal names the profile rather than - * rebuilding the section: this page only ever holds the redacted descriptor, - * so a rebuilt section would drop every literal secret stored elsewhere in - * the namespace along with the profile being removed. - * @param api - settings wire face. + * Remove one user-added provider and its page-managed credential. Credential + * removal comes first so a second-step failure leaves the provider row visible + * and the whole operation safely retryable; both unsets are idempotent. + * The settings removal names the profile rather than rebuilding its redacted + * namespace, which would drop literal secrets stored elsewhere. + * @param api - settings and credential wire faces. * @param controller - the page store to refresh. - * @param target - the provider's settings address. + * @param target - the provider's settings address and optional managed credential. * @returns the failure message, or undefined once the write and reload landed. */ export async function removeProviderProfile( - api: Pick<IApiClient, 'settings'>, + api: Pick<IApiClient, 'settings' | 'credentials'>, controller: ModelsSettingsStore, - target: { settingsNs: string; settingsPath: readonly string[] }, + target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise<string | undefined> { - let response try { - response = await api.settings.mutate({ + if (target.credentialRef !== undefined) { + const credential = await api.credentials.unset({ ref: target.credentialRef }) + if (!credential.result.ok) return credential.result.error.message + } + const response = await api.settings.mutate({ ns: target.settingsNs, ops: [{ op: 'unset', path: [...target.settingsPath] }], }) + if (!response.result.ok) return response.result.error.message } catch (error) { // The transport rejected rather than answering; the caller must be able - // to say so instead of the row silently staying put. + // to retry the idempotent operation instead of the row silently staying. return messageOf(error) } - if (!response.result.ok) return response.result.error.message await controller.load() return undefined } @@ -92,14 +103,33 @@ export function needsSetup(row: ProviderRow): boolean { } function targetOf(row: ProviderRow): EditorTarget { + const managedRef = deriveKeyRef(row.entry.provider) + const credentialRef = row.apiKeyEnv === managedRef + && row.credential?.configured === true + && row.credential.writable + ? managedRef + : undefined return { provider: row.entry.provider, displayName: row.entry.displayName, settingsNs: row.entry.settingsNs, settingsPath: row.entry.settingsPath, + ...credentialRef === undefined ? {} : { credentialRef }, } } +/** Stable visible and accessible identity for one provider target. */ +export function providerTargetLabel(target: ProviderIdentity): string { + return target.provider === target.displayName + ? target.provider + : `${target.displayName} (${target.provider})` +} + +/** Replace the one provider placeholder in localized destructive-action copy. */ +export function providerCopy(template: string, target: ProviderIdentity): string { + return template.replace('{provider}', () => providerTargetLabel(target)) +} + /** * Render the Models section content column. * @param props - slot-delivered injected dependencies. @@ -118,6 +148,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [adding, setAdding] = useState(false) const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined) const [deleting, setDeleting] = useState(false) + const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined) const closeEditor = (changed: boolean): void => { setEditing(undefined) @@ -128,16 +159,18 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const closeDelete = (): void => { if (deleting) return setDeleteTarget(undefined) + setDeleteFailure(undefined) } const confirmDelete = (): void => { /* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */ if (deleteTarget === undefined || deleting) return setDeleting(true) + setDeleteFailure(undefined) void removeProviderProfile(api, controller, deleteTarget) .then((failure) => { if (failure !== undefined) { - controller.fail(failure) + setDeleteFailure(failure) return } setDeleteTarget(undefined) @@ -202,6 +235,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { <button type="button" className={styles['secondaryButton']} + aria-label={providerCopy(t('editProvider'), target)} onClick={() => { setAdding(false); setEditing(open ? undefined : target) }} > {t('edit')} @@ -211,8 +245,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { <button type="button" className={styles['dangerButton']} + aria-label={providerCopy(t('removeProvider'), target)} disabled={!state.writable} - onClick={() => { setDeleteTarget(target) }} + onClick={() => { setDeleteFailure(undefined); setDeleteTarget(target) }} > {t('remove')} </button> @@ -296,9 +331,16 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { <Modal open={deleteTarget !== undefined} onClose={closeDelete} - title={t('deleteTitle')} + title={deleteTarget === undefined ? '' : providerCopy(t('deleteTitle'), deleteTarget)} closeLabel={t('close')} - description={t('deleteDescription')} + description={deleteTarget === undefined + ? '' + : providerCopy( + deleteTarget.credentialRef === undefined + ? t('deleteDescription') + : t('deleteDescriptionWithCredential'), + deleteTarget, + )} className={styles['deleteDialog'] as string} footer={( <> @@ -311,11 +353,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { disabled={deleting} onClick={confirmDelete} > - {deleting ? t('deleting') : t('deleteConfirm')} + {deleteTarget === undefined + ? '' + : providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)} </Button> </> )} - /> + > + {deleteFailure === undefined ? null : <p className={styles['error']}>{deleteFailure}</p>} + </Modal> </div> ) } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 0f89f329c0..46350a145d 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -3,7 +3,9 @@ * field is a single write-only **API key** input (the page never asks for an * environment-variable name — a typed key stores through `credentials.set` * under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile - * has none, and the pi-ai profile records that derivation as `apiKeyEnv`); + * has none. The pi-ai profile records that derivation as `apiKeyEnv` only when + * a key is entered; a blank key materializes a reference-free profile for + * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and * DeepSeek's id/name/context-window model catalog). Everything else stays @@ -131,10 +133,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState<string | undefined>(undefined) - // The revision this card opened at. A write carrying it is refused if - // anything else — another tab, an external edit of settings.yaml — moved the - // namespace meanwhile, instead of silently overwriting that change. - const [openedAt] = useState(() => namespace.revision) + // A settings success becomes the next retry baseline immediately. If the + // following credential write fails, retry sends only the credential instead + // of replaying the already-committed settings write with a stale revision. + const [committedOriginal, setCommittedOriginal] = useState<unknown>( + () => getPath(namespace.user, settingsPath), + ) + const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision) const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema]) const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath]) const fallback = getPath(namespace.value, settingsPath) @@ -176,11 +181,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { */ const applyOnce = async (): Promise<string | undefined> => { const ns = namespace.ns - const original = getPath(namespace.user, settingsPath) - // The pi-ai profile must name the reference the key stores under, so a - // dormant add (or a legacy profile without one) records the derivation. + const normalizedKey = keyDraft.trim() + // A pi-ai profile names the conventional reference only when this page is + // about to store a key. Otherwise the provider keeps its native auth path. const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined - && stringAt(fallback, 'apiKeyEnv') === undefined + && stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0 ? setPath(draft, ['apiKeyEnv'], keyRef) : draft if (layout === 'deepseek') { @@ -194,17 +199,25 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const sectionError = validateDraft(node, next) if (sectionError !== undefined) return sectionError } - const ops = pathOps(settingsPath, original, next) + const materializesNativeProfile = layout === 'pi-ai' + && fallback === undefined + && committedOriginal === undefined + && Object.keys(next).length === 0 + const ops: SettingsPathOpView[] = materializesNativeProfile + ? [{ op: 'set', path: [...settingsPath], value: {} }] + : pathOps(settingsPath, committedOriginal, next) if (ops.length > 0) { - const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt }) + const response = await api.settings.mutate({ ns, ops, expectedRevision }) if (!response.result.ok) { return response.result.error.code === 'settings-conflict' ? t('conflict') : response.result.error.message } + setCommittedOriginal(getPath(response.result.value.user, settingsPath)) + setExpectedRevision(response.result.value.revision) } - if (keyDraft.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + if (normalizedKey.length > 0) { + const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey }) if (!stored.result.ok) return stored.result.error.message } setKeyDraft('') @@ -263,6 +276,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) const defaultMaxTokens = getPath(fallback, ['maxTokens']) + const keyPlaceholder = keyLocked + ? t('keyEnvLocked') + : keyState?.configured === true + ? t('keyStored') + : family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder') return ( <> <div className={styles['field']}> @@ -272,9 +290,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { type="password" autoComplete="off" value={keyDraft} - placeholder={keyLocked - ? t('keyEnvLocked') - : keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')} + placeholder={keyPlaceholder} aria-label={t('keyInput')} disabled={disabled || keyLocked} onChange={(event) => { setKeyDraft(event.target.value) }} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index bb1254e46b..4fc76695e4 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -6,11 +6,14 @@ export const en = { title: 'Models', intro: 'Enter your API keys to use models from the following providers.', edit: 'Edit', + editProvider: 'Edit {provider}', remove: 'Delete', - deleteTitle: 'Delete model provider?', - deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.', - deleteConfirm: 'Delete provider', - deleting: 'Deleting provider…', + removeProvider: 'Delete {provider}', + deleteTitle: 'Delete {provider}?', + deleteDescription: 'Deleting {provider} removes its configuration. Its credential is managed elsewhere and will be kept.', + deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.', + deleteConfirm: 'Delete {provider}', + deleting: 'Deleting {provider}…', add: 'Add provider', provider: 'Provider', close: 'Close', @@ -23,6 +26,7 @@ export const en = { retry: 'Retry', keyInput: 'API key', keyPlaceholder: 'Enter your API key', + keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication', keyStored: 'Configured — enter a new value to replace', keyEnvLocked: 'Provided by the launch environment (read-only)', customized: 'Customized settings', @@ -67,11 +71,14 @@ export const zh: typeof en = { title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', edit: '编辑', + editProvider: '编辑 {provider}', remove: '删除', - deleteTitle: '删除模型提供方?', - deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。', - deleteConfirm: '删除提供方', - deleting: '正在删除提供方…', + removeProvider: '删除 {provider}', + deleteTitle: '删除 {provider}?', + deleteDescription: '删除 {provider} 会移除其配置;凭证由其他位置管理,将会保留。', + deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。', + deleteConfirm: '删除 {provider}', + deleting: '正在删除 {provider}…', add: '添加提供方', provider: '提供方', close: '关闭', @@ -84,6 +91,7 @@ export const zh: typeof en = { retry: '重试', keyInput: 'API 密钥', keyPlaceholder: '输入 API 密钥', + keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证', keyStored: '已配置——输入新值可替换', keyEnvLocked: '由启动环境提供(只读)', customized: '自定义设置', diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 282f21fe75..7f1009e656 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -103,18 +103,6 @@ export class ModelsSettingsStore { */ constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {} - /** - * Surface a failure from an operation the page ran outside {@link load} — - * a row removal — on the same banner a load failure uses. - * @param message - the failure text to show. - */ - fail(message: string): void { - this.store.update((s) => { - s.status = 'error' - s.error = message - }) - } - /** * Refresh the whole page snapshot: directory and namespaces in parallel, * then one batched credential describe over every referenced ref. A diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index c668675be0..2842b94554 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -53,7 +53,7 @@ describe('ui-models apply', () => { expect(resolveSlotLabel(entry.options.label)).toBe('模型') const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)() expect(injected.t('nav')).toBe('模型') - expect(injected.t('deleteTitle')).toBe('删除模型提供方?') + expect(injected.t('deleteTitle')).toBe('删除 {provider}?') expect(typeof injected.controller.load).toBe('function') expect(typeof injected.useSnapshot).toBe('function') expect(injected.api).toBeDefined() @@ -80,10 +80,10 @@ describe('ui-models apply', () => { b.locale.setLocale('en') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models') const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected - expect(injected().t('deleteTitle')).toBe('Delete model provider?') + expect(injected().t('deleteTitle')).toBe('Delete {provider}?') b.locale.setLocale('zh') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型') - expect(injected().t('deleteTitle')).toBe('删除模型提供方?') + expect(injected().t('deleteTitle')).toBe('删除 {provider}?') }) it('locale change while the slot is undeclared stays a no-op', async () => { diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index aa9082e7dd..e28df6564d 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -5,7 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from 'schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx' +import { + ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, +} from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { @@ -18,6 +20,8 @@ import { en } from '../src/client/locales.ts' afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] +const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' } +const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET) /** Open one row's capacity disclosure (1-based, as the labels read). */ function expandRow(position: number): void { @@ -136,11 +140,13 @@ function scriptedFace(overrides: { replace?: ReturnType<typeof vi.fn> mutate?: ReturnType<typeof vi.fn> set?: ReturnType<typeof vi.fn> + unset?: ReturnType<typeof vi.fn> } = {}) { const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({}))) + const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({}))) const face = { llm: { providers: vi.fn(() => Promise.resolve(ok({ @@ -170,16 +176,16 @@ function scriptedFace(overrides: { }])), }))), set, - unset: vi.fn(() => Promise.resolve(ok({}))), + unset, }, } - return { face, update, replace, mutate, set } + return { face, update, replace, mutate, set, unset } } type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0] async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) { - const { face, update, replace, mutate, set } = scriptedFace(overrides) + const { face, update, replace, mutate, set, unset } = scriptedFace(overrides) const controller = new ModelsSettingsStore(face as unknown as WireFace) await controller.load() const injected: ModelsSectionInjected = { @@ -189,7 +195,7 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) t, } const view = render(<ModelsSection {...injected} />) - return { view, face, update, replace, mutate, set, controller } + return { view, face, update, replace, mutate, set, unset, controller } } describe('ModelsSection', () => { @@ -254,6 +260,13 @@ describe('ModelsSection', () => { expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY') }) + it('uses one stable provider identity in action copy', () => { + const target = { provider: 'deepseek-official', displayName: 'DeepSeek' } + expect(providerTargetLabel(target)).toBe('DeepSeek (deepseek-official)') + expect(providerCopy(en.deleteTitle, target)).toBe('Delete DeepSeek (deepseek-official)?') + expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai') + }) + it('names only the fields the card can see, so an unseen secret survives', () => { // `before` is the REDACTED subtree: a stored literal apiKey is in neither // side, so no op mentions it and the seam leaves it alone. @@ -268,7 +281,7 @@ describe('ModelsSection', () => { it('stores a typed key write-only from the setup card without touching settings', async () => { const { set, update, face } = await mountSection() const key = screen.getByLabelText<HTMLInputElement>(en.keyInput) - fireEvent.change(key, { target: { value: 'sk-live' } }) + fireEvent.change(key, { target: { value: ' sk-live ' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) }) expect(update).not.toHaveBeenCalled() @@ -777,6 +790,7 @@ describe('ModelsSection', () => { expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault) const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput) const addKey = keys[keys.length - 1] as HTMLInputElement + expect(addKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(addKey, { target: { value: 'sk-ant' } }) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -788,6 +802,52 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) }) }) + it('keeps pi-ai provider-native authentication when no key is entered', async () => { + const { mutate, set } = await mountSection() + fireEvent.click(screen.getByText(en.add)) + await screen.findByLabelText(en.provider) + fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-pi-ai', + ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }], + expectedRevision: 0, + }) + expect(set).not.toHaveBeenCalled() + }) + + it('retries only the credential after settings already committed', async () => { + const committed = wireNamespaces()[2]! + const afterSettings: SettingsNamespaceView = { + ...committed, + value: { providers: { + ...(committed.value as { providers: object }).providers, + anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' }, + } }, + user: { providers: { + ...(committed.user as { providers: object }).providers, + anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' }, + } }, + revision: 1, + } + const mutate = vi.fn(() => Promise.resolve(ok(afterSettings))) + const set = vi.fn() + .mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected')) + .mockResolvedValueOnce(ok({})) + await mountSection({ mutate, set }) + fireEvent.click(screen.getByText(en.add)) + await screen.findByLabelText(en.provider) + const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput) + fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } }) + fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + await screen.findByText('credential store unavailable') + expect(mutate).toHaveBeenCalledOnce() + fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) + expect(mutate).toHaveBeenCalledOnce() + expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) + }) + it('switches the add card target and degrades unknown or broken targets loudly', async () => { await mountSection() fireEvent.click(screen.getByText(en.add)) @@ -898,34 +958,37 @@ describe('ModelsSection', () => { fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput) const editorKey = keys[keys.length - 1] as HTMLInputElement - expect(editorKey.placeholder).toBe(en.keyPlaceholder) + expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(editorKey, { target: { value: 'sk-live' } }) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) it('requires confirmation before removing a user-added provider', async () => { - const { replace, mutate } = await mountSection() - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) - expect(dialog.textContent).toContain(en.deleteDescription) + const { replace, mutate, unset } = await mountSection() + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }) + expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential)) expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel })) + expect(unset).not.toHaveBeenCalled() expect(mutate).not.toHaveBeenCalled() fireEvent.click(within(dialog).getByRole('button', { name: en.cancel })) - expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() expect(mutate).not.toHaveBeenCalled() - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })) .getByRole('button', { name: en.close })) - expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() expect(mutate).not.toHaveBeenCalled() - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) - .getByRole('button', { name: en.deleteConfirm })) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })) + .getByRole('button', { name: openaiCopy(en.deleteConfirm) })) + await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) }) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number) + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() expect(replace).not.toHaveBeenCalled() expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -939,20 +1002,22 @@ describe('ModelsSection', () => { resolveRemoval = resolve })) await mountSection({ mutate }) - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) - const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm }) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }) + const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.deleteConfirm) }) fireEvent.click(confirm) fireEvent.click(confirm) - expect(mutate).toHaveBeenCalledOnce() + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) expect(confirm.disabled).toBe(true) expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true) - expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm) + expect(within(dialog).getByRole('button', { name: openaiCopy(en.deleting) })).toBe(confirm) fireEvent.click(within(dialog).getByRole('button', { name: en.close })) - expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog) + expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog) expect(mutate).toHaveBeenCalledOnce() await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) }) - await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() }) + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() + }) }) it('renders the load failure with a retry control', async () => { @@ -1057,15 +1122,58 @@ describe('ModelsSection', () => { expect(controller.store.getSnapshot().rows).toBe(before) }) - it('shows a failed removal on the page banner, including a non-Error rejection', async () => { - // The whole click path: the row's Remove button, the transport rejecting - // with a non-Error value, and the store surfacing it where a load failure - // would appear — rather than the row silently staying put. - await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) }) - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) - .getByRole('button', { name: en.deleteConfirm })) - await screen.findByText(`${en.loadFailed}: the host refused`) + it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => { + const mutate = vi.fn() + .mockResolvedValueOnce(fail('the host refused')) + .mockResolvedValueOnce(ok(wireNamespaces()[2]!)) + const { unset } = await mountSection({ mutate }) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }) + const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) }) + fireEvent.click(confirm) + await within(dialog).findByText('the host refused') + expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog) + expect(unset).toHaveBeenCalledOnce() + expect(mutate).toHaveBeenCalledOnce() + + fireEvent.click(confirm) + await waitFor(() => { expect(unset).toHaveBeenCalledTimes(2) }) + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(2) }) + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() + }) + }) + + it('retains credentials that are not identified as page-managed', async () => { + const { unset, mutate } = await mountSection() + const target = { provider: 'zombie', displayName: 'zombie' } + fireEvent.click(screen.getByRole('button', { name: providerCopy(en.removeProvider, target) })) + const dialog = screen.getByRole('dialog', { name: providerCopy(en.deleteTitle, target) }) + expect(dialog.textContent).toContain(providerCopy(en.deleteDescription, target)) + fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) })) + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + expect(unset).not.toHaveBeenCalled() + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-pi-ai', + ops: [{ op: 'unset', path: ['providers', 'zombie'] }], + }) + }) + + it('does not remove provider settings when its managed credential removal is refused', async () => { + const { face, controller, mutate } = await mountSection({ + unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))), + }) + const failure = await removeProviderProfile( + face as unknown as Parameters<typeof removeProviderProfile>[0], + controller, + { + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'openai'], + credentialRef: 'OPENAI_API_KEY', + }, + ) + expect(failure).toBe('credential is read-only') + expect(mutate).not.toHaveBeenCalled() }) it('reports a transport rejection instead of failing the removal silently', async () => { diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 3eb54a7a9f..c0e02b2a57 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 0cd265cadb2b2a619613761062ab2cef209bec83 -README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f +README.md: b583ecadf23ec4d089bfc9473dc1165c3e70ae9a +README.zh.md: 42d38e913b98b9ed2cf1781fdc6f716a0050c905 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 0cd265cadb..b583ecadf2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -53,7 +53,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1883b05427..42d38e913b 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -53,7 +53,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cd2bb9a24e..d55cea3ded 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -58,7 +58,10 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Trimmed literal API key; whitespace-only is absent. Prefer + * {@link apiKeyEnv} to keep secrets out of configuration files. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string @@ -153,6 +156,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee * @returns validated connection facts plus the credential reference. */ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { + const apiKey = config.apiKey?.trim() if (config.thinking === 'disabled' && config.reasoningEffort !== undefined && config.reasoningEffort !== 'off') { @@ -175,7 +179,7 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { ) } return { - ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, + ...apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9d104ace08..7146913bed 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -699,6 +699,13 @@ describe('plugin registration and config', () => { }) }) + it('normalizes a literal API key and treats whitespace as absent', () => { + expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key') + const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' }) + expect(whitespace.apiKey).toBeUndefined() + expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY') + }) + it('uses the default model catalog when apply is called directly', async () => { const ctx = new Context() await ctx.plugin(LlmService) From 2b9354aab58597ce17b4b4d270e24ba882b21c73 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 12:13:28 +0800 Subject: [PATCH 109/190] docs(todo): state the catalog's policy branch and drop the sidecar census The tool catalog boots every tool with its default config, but allowParallelInProgress has no default, so the generator has to choose; the page now says which branch it shows. The Agent Note's sidecar count went stale twice inside this PR, so it records the refresh rule instead of a point-in-time census. The SDK builtin entry pins its config literal with satisfies like every sibling. --- .../feature/2026-07-23-web-todo-display.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-23-web-todo-display.md | 2 +- .../implemented/feature/2026-07-23-web-todo-display.zh.md | 2 +- .../feature/2026-07-26-todo-parallel-in-progress.i18n.yaml | 4 ++-- .../feature/2026-07-26-todo-parallel-in-progress.md | 2 +- .../feature/2026-07-26-todo-parallel-in-progress.zh.md | 2 +- docs/tool-catalog.md | 6 +++--- packages/client/connection/tests/fixture.spec.ts | 4 ++-- packages/sdk/helper/package.json | 1 + packages/sdk/helper/src/features/builtin/index.ts | 3 ++- packages/sdk/helper/tsconfig.json | 3 +++ pnpm-lock.yaml | 3 +++ scripts/gen-tool-catalog.ts | 4 ++-- 13 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 3f2ed47be7..2a32b80867 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: b9782d2946086feac3bac4f4361de543b5e93e69 -2026-07-23-web-todo-display.zh.md: e2311d40c8b246add39657162d2fcf3206da24b1 +2026-07-23-web-todo-display.md: 338534d2d2eeb4b1d6df79b32f0d4ec5b6695d39 +2026-07-23-web-todo-display.zh.md: d6e3c2f56ac0251e59a434cd76c198b0434991be diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index b9782d2946..338534d2d2 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -33,4 +33,4 @@ The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview` ## Consequences -Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 65) plus the assembled keyless snapshot (`apps/web/tests/todo-display.snapshot.ts`) pin the full chain (row summary and state, dock panel content, collapse round-trip) over the built client graph. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel shares the same turn-scoped lifetime (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel shares the same turn-scoped lifetime (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index e2311d40c8..d6e3c2f56a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -33,4 +33,4 @@ Status: implemented ## 后果 -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 65 轮的 fixture(测试前置数据)加上组装后的无密钥快照(`apps/web/tests/todo-display.snapshot.ts`)在构建后的完整客户端依赖图中固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板共用同一按轮次界定的生命周期(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板共用同一按轮次界定的生命周期(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 65deab6f74..5efe7a5816 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 170c0d205a95b0668e8da0997a04849aae2bd59e -2026-07-26-todo-parallel-in-progress.zh.md: 2ff13750bb2f19acccd09ffdd6d4937729e63803 +2026-07-26-todo-parallel-in-progress.md: 310763977862cf7b170a8901d636ce824fca304d +2026-07-26-todo-parallel-in-progress.zh.md: e00c8357cac1dbc9bb82388b3e25304f96e80b3b diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 170c0d205a..3107639778 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -47,4 +47,4 @@ The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first a ## Consequences -A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every `tool-schemas.expected.json` sidecar carrying the todo schema (seven of the eight in the tree). Scenarios composing an identical header share one sidecar through `toolSchemasSource` rather than each keeping a copy, so the count tracks distinct header compositions, not scenarios; a branch changing the tool description still has to refresh whichever sidecars landed after it branched — `pnpm run test:snapshot:refresh` does it keylessly. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan — `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip, and the ACP `todo-write` scenario records a three-todo plan with two active — and each would fail again if its derivation returned to single-active. +A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas.<n>.expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan — `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip, and the ACP `todo-write` scenario records a three-todo plan with two active — and each would fail again if its derivation returned to single-active. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index 2ff13750bb..e00c8357ca 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -47,4 +47,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的 `tool-schemas.expected.json` sidecar(树中八个里有七个)。组合出相同 header 的场景通过 `toolSchemasSource` 共用同一份 sidecar,而非各自保留副本,因此这个数量对应的是不同的 header 组合,而不是场景数;改动工具描述的分支仍须刷新它分叉之后落地的那些 sidecar —— `pnpm run test:snapshot:refresh` 可以无 key 完成。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划——`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划——任一推导退回单活跃项,对应的测试都会失败。 +现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划——`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划——任一推导退回单活跃项,对应的测试都会失败。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c177a4984a..57af22435f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -7,7 +7,7 @@ Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog Agent Note](../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md). -Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config, except where a Config field is REQUIRED with no default — there the generator must choose, and the per-package note records which branch this page shows. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. ## Tool Package Map @@ -35,7 +35,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-subagent-control` | `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.sessionQuery (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | -| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | +| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -1376,7 +1376,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l Source: [`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts) -todo_write is session-owned state; UIs render the latest todo/write event as a checklist. +todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. ## `@deepseek-ai/dsh-tool-workflow` diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0e6b2fbf3d..f608190936 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -242,8 +242,8 @@ describe('createFixtureApi', () => { const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time) expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0) expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0) - // The sample is a parallel plan: the tool permits several in_progress, so - // the surfaces fed from here are exercised against more than one active item. + // The sample is a parallel plan: this fixture chooses the parallel policy, + // so the surfaces fed from here face more than one active item. const snapshot = events[todoAt] as { data: { todos: { status: string }[] } } expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2) }) diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index bf9eacb813..bed70dbbda 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 5022c063d5..abdcc35dd5 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -10,6 +10,7 @@ import type { Config as CodexHooksConfig } from '@deepseek-ai/dsh-hooks-codex' import type { Config as JsonlConfig } from '@deepseek-ai/dsh-session-persistence-jsonl' import type { Config as SqliteConfig } from '@deepseek-ai/dsh-session-persistence-sqlite' import type { Config as ToolSubagentConfig } from '@deepseek-ai/dsh-tool-subagent' +import type { Config as ToolTodoConfig } from '@deepseek-ai/dsh-tool-todo' import type { Config as ToolWebConfig } from '@deepseek-ai/dsh-tool-web' import type { ProjectProfile } from '../../project/types.ts' import { defineFeatures } from '../define-feature.ts' @@ -130,7 +131,7 @@ config: kind: 'npm-cordis-config-entry', id: 'tool-todo', package: '@deepseek-ai/dsh-tool-todo', - config: { allowParallelInProgress: true }, + config: { allowParallelInProgress: true } satisfies ToolTodoConfig, }], }], }, diff --git a/packages/sdk/helper/tsconfig.json b/packages/sdk/helper/tsconfig.json index b1a3b7a61a..528b370a5c 100644 --- a/packages/sdk/helper/tsconfig.json +++ b/packages/sdk/helper/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../../subagent/tool-subagent" }, + { + "path": "../../todo/tool-todo" + }, { "path": "../../web/tool-web" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c194d675e7..1cf1c8d902 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4330,6 +4330,9 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../subagent/tool-subagent + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../todo/tool-todo '@deepseek-ai/dsh-tool-web': specifier: workspace:^ version: link:../../web/tool-web diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 0fcd202198..b2c9204444 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -457,7 +457,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) }, note: - 'todo_write is session-owned state; UIs render the latest todo/write event as a checklist.', + 'todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task.', }, { pkg: '@deepseek-ai/dsh-tool-workflow', @@ -610,7 +610,7 @@ export function render(catalog: ToolCatalog): string { '', 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog Agent Note](../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md).', '', - 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config, except where a Config field is REQUIRED with no default — there the generator must choose, and the per-package note records which branch this page shows. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', '## Tool Package Map', '', From 0cf7420e52e39efd26570b9ca13b6bf699bcfcd2 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 12:26:27 +0800 Subject: [PATCH 110/190] test(web): pin the parallel todo plan in the assembled application The `+N` active count rides ToolRow's non-shrinking `summarySuffix` slot, but only jsdom package suites covered it: the ACP snapshots render no web surface and the built-boot smoke asserts no todo row. Add `apps/web/tests/todo-row.snapshot.ts`, which boots the built client bundles against the keyless fixture transport and records `summary`, `suffix`, and the plan strip's header as separate fields, so folding the count back into the summary string changes the expected output. The three assembled-jsdom files now share `apps/web/tests/assembled-boot.ts` instead of each carrying its own copy of the boot entry list, bundle map, jsdom stubs, and mount call. Also: name the policy branch in each `allowParallelInProgress` test title so no case asserting `true` sits under a `false` describe, reword the stale cap comment in todo-panel.spec.tsx, and record the plan strip's real header format in the Agent Note (per-status counts, not `<done>/<total> tasks`). --- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 4 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 4 +- apps/web/tests/assembled-boot.ts | 126 ++++++++++++++++++ apps/web/tests/built-boot.snapshot.ts | 92 +------------ apps/web/tests/search-card.snapshot.ts | 103 +------------- .../todo-row/parallel-plan.expected.txt | 9 ++ apps/web/tests/todo-row.snapshot.ts | 72 ++++++++++ .../ui-conversation/tests/todo-panel.spec.tsx | 2 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 8 +- 10 files changed, 230 insertions(+), 194 deletions(-) create mode 100644 apps/web/tests/assembled-boot.ts create mode 100644 apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt create mode 100644 apps/web/tests/todo-row.snapshot.ts diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 5efe7a5816..9dd6946ab5 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 310763977862cf7b170a8901d636ce824fca304d -2026-07-26-todo-parallel-in-progress.zh.md: e00c8357cac1dbc9bb82388b3e25304f96e80b3b +2026-07-26-todo-parallel-in-progress.md: b380ee86154f8436416126725a7bb486f6dc052d +2026-07-26-todo-parallel-in-progress.zh.md: f007cc5645df211adc8e391f72ad2d90666e9c0f diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 3107639778..b380ee8615 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -37,7 +37,7 @@ The durable-log invariant deliberately does NOT follow the flag. A log written w ## The display surfaces are part of the change -Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · <one task>` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with a `<done>/<total> tasks · <n> in progress` count, which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. +Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · <one task>` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists. @@ -47,4 +47,4 @@ The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first a ## Consequences -A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas.<n>.expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan — `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip, and the ACP `todo-write` scenario records a three-todo plan with two active — and each would fail again if its derivation returned to single-active. +A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas.<n>.expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index e00c8357ca..f007cc5645 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -37,7 +37,7 @@ Status: implemented ## 展示面是本次改动的一部分 -解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成 `<done>/<total> tasks · <n> in progress` 计数,它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 +解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。 @@ -47,4 +47,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划——`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划——任一推导退回单活跃项,对应的测试都会失败。 +现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts new file mode 100644 index 0000000000..c2c9f49e08 --- /dev/null +++ b/apps/web/tests/assembled-boot.ts @@ -0,0 +1,126 @@ +// Shared scaffolding for the assembled-jsdom snapshots: the real built +// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's +// ModuleLoader path (loadBundle) against the keyless FixtureApiClient +// transport. Every file that mounts this graph needs the same boot entry list, +// the same bundle map, the same jsdom globals, and the same mount call, and +// differs only in what it asserts afterwards, so the scaffolding lives here. +// +// Keyless and deterministic: the fixture is the fake server, so nothing here +// reaches a model or the network. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup } from '@testing-library/react' +import { afterEach, beforeEach, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */ +export const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +/** + * Register the per-test jsdom setup and teardown the assembled boot needs: + * English pinned before boot so role/text locators stay deterministic across + * localized component migrations (the newEnglishPage e2e convention), the + * observers and frame callbacks jsdom lacks, and a full reset of the document, + * the boot globals, and the injected plugin styles afterwards. + */ +export function installAssembledBootEnv(): void { + beforeEach(() => { + localStorage.clear() + localStorage.setItem('dsh.locale', 'en') + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) + }) + + afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() + }) +} + +/** + * Mount the assembled application on the fixture transport; the teardown + * registered by installAssembledBootEnv disposes it. + */ +export function mountAssembledApp(): void { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + loadBundle: async (url) => { + const code = bundles.get(url) + if (code === undefined) throw new Error(`missing built bundle ${url}`) + ;(0, eval)(code) + }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** + * Match a CSS-module class by its logical name. + * Module class names carry a per-build hash in one of two schemes — + * ui-primitives emits `_<name>_<hash>` (name bounded by underscores), + * ui-conversation emits `<hash>_<name>` (name at the end) — and a longer name + * containing this one must not match (`line` must not hit `lineNumber`). + * @param el - element whose class list is inspected. + * @param name - logical (unhashed) module class name. + * @returns whether the element carries that module class. + */ +export function hasClass(el: Element, name: string): boolean { + return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) +} diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index b102dff8b8..ef6ee9b70e 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -10,96 +10,14 @@ // benches over src). This smoke additionally pins the resident interaction // fixture's cross-plugin projection because only the built connection/runtime/ // workspace graph can prove that transport-to-row path end to end. -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' -import { AppWebEntry } from '@deepseek-ai/dsh-client-web' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' -const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { - id: '@deepseek-ai/dsh-client-ui-workspace', - dir: 'ui-workspace', - url: '/plugins/ui-workspace.js', - rev: 'fx', - inject: [ - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-ui-conversation', - '@deepseek-ai/dsh-client-ui-sidebar', - ], - }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, -] - -const bundles = new Map(PLUGINS.map(plugin => [ - plugin.url, - readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), -])) - -interface FixtureWindow extends Window { - __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } - __ModuleLoader__?: unknown -} - -class ResizeObserverStub { - observe(): void {} - disconnect(): void {} - unobserve(): void {} -} - -const win = window as FixtureWindow -let unmount: (() => void) | undefined - -beforeEach(() => { - localStorage.clear() - // English pinned before boot: role/text locators stay deterministic across - // localized component migrations (the newEnglishPage e2e convention). - localStorage.setItem('dsh.locale', 'en') - document.title = 'DeepSeek Harness' - vi.stubGlobal('ResizeObserver', ResizeObserverStub) - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - setTimeout(() => { callback(0) }, 0) as unknown as number) - vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) -}) - -afterEach(() => { - act(() => { unmount?.() }) - unmount = undefined - cleanup() - delete win.__DSH_BOOT__ - delete win.__ModuleLoader__ - document.body.innerHTML = '' - document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) - document.title = '' - history.replaceState(null, '', '/') - vi.unstubAllGlobals() -}) +installAssembledBootEnv() it('boots the built plugin graph and renders a fixture session end to end', async () => { - history.replaceState(null, '', '/?fixture') - const root = document.createElement('div') - root.id = 'root' - document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } - act(() => { - const entry = new AppWebEntry(root, { - loadBundle: async (url) => { - const code = bundles.get(url) - if (code === undefined) throw new Error(`missing built bundle ${url}`) - ;(0, eval)(code) - }, - }) - void entry.run() - unmount = () => { entry.dispose() } - }) + mountAssembledApp() // The sidebar renders from the boot graph: every inject layer activated. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 2f72c8275c..745b545293 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -14,69 +14,20 @@ // derivation over the result view, pinned at every render site by the // ui-conversation suite; here the fixture turn exercises the assembled card // shape and its cap. -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' -import { AppWebEntry } from '@deepseek-ai/dsh-client-web' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt') const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' -const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { - id: '@deepseek-ai/dsh-client-ui-workspace', - dir: 'ui-workspace', - url: '/plugins/ui-workspace.js', - rev: 'fx', - inject: [ - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-ui-conversation', - '@deepseek-ai/dsh-client-ui-sidebar', - ], - }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, -] - -const bundles = new Map(PLUGINS.map(plugin => [ - plugin.url, - readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), -])) - -interface FixtureWindow extends Window { - __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } - __ModuleLoader__?: unknown -} - -class ResizeObserverStub { - observe(): void {} - disconnect(): void {} - unobserve(): void {} -} - -const win = window as FixtureWindow -let unmount: (() => void) | undefined +installAssembledBootEnv() /** Normalize a rendered search card to a stable text shape: the kind, the banner * summary, each file header (path + count), each visible match line, the expand - * control label, and the recovery footer. CSS-module class names carry a - * per-build hash in one of two schemes — ui-primitives emits `_<name>_<hash>` - * (name bounded by underscores), ui-conversation emits `<hash>_<name>` (name at - * the end). `hasClass` matches a module class by its logical name under either, - * without matching a longer name that contains it (`line` must not hit - * `lineNumber`). */ -function hasClass(el: Element, name: string): boolean { - return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) -} - + * control label, and the recovery footer. */ function cardShape(root: Element): string { const card = root.querySelector('[data-search]') if (card === null) return '<no search card>' @@ -94,49 +45,9 @@ function cardShape(root: Element): string { return lines.join('\n') } -beforeEach(() => { - localStorage.clear() - // English pinned before boot so the sidebar's role/text locators stay - // deterministic (the built-boot smoke's convention). - localStorage.setItem('dsh.locale', 'en') - document.title = 'DeepSeek Harness' - vi.stubGlobal('ResizeObserver', ResizeObserverStub) - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - setTimeout(() => { callback(0) }, 0) as unknown as number) - vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) -}) - -afterEach(() => { - act(() => { unmount?.() }) - unmount = undefined - cleanup() - delete win.__DSH_BOOT__ - delete win.__ModuleLoader__ - document.body.innerHTML = '' - document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) - document.title = '' - history.replaceState(null, '', '/') - vi.unstubAllGlobals() -}) - describe('assembled search card', () => { it('renders the grep card, its truncation summary, and its capped head/tail slice from the built bundles', async () => { - history.replaceState(null, '', '/?fixture') - const root = document.createElement('div') - root.id = 'root' - document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } - act(() => { - const entry = new AppWebEntry(root, { - loadBundle: async (url) => { - const code = bundles.get(url) - if (code === undefined) throw new Error(`missing built bundle ${url}`) - ;(0, eval)(code) - }, - }) - void entry.run() - unmount = () => { entry.dispose() } - }) + mountAssembledApp() const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) fireEvent.click(await within(tree).findByText('Fixture 历史会话')) diff --git a/apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt b/apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt new file mode 100644 index 0000000000..22c2ac777a --- /dev/null +++ b/apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt @@ -0,0 +1,9 @@ +row=todo_write +title=Update to-do list +summary=1/4 completed · 实现 fixture 样本 +suffix=+1 +panel=1 completed · 2 in progress · 1 pending +item=completed 梳理需求 +item=in_progress 实现 fixture 样本 +item=in_progress 跑后台构建 +item=pending 浏览器验收 \ No newline at end of file diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts new file mode 100644 index 0000000000..70fd95c447 --- /dev/null +++ b/apps/web/tests/todo-row.snapshot.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +// Assembled todo snapshot: boots the real built `packages/client/*/lib/ +// client.js` bundles through AppWebEntry's ModuleLoader path against the +// keyless FixtureApiClient transport, opens the fixture session, and pins the +// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`) +// reaches — the `todo_write` tool row and the dock's plan strip. +// +// The row is pinned as three separate fields on purpose. `summary=` is the +// ellipsized text and `suffix=` is ToolRow's non-shrinking `summarySuffix` +// slot, so a regression that folds the `+N` count back into the summary string +// changes this file even though the concatenated text would read the same; the +// jsdom package suites bench over src and cannot see the bundled registration. +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt') +const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' + +installAssembledBootEnv() + +/** Normalize the todo row and the plan strip to a stable text shape: the row's + * title, its truncatable summary, its non-shrinking suffix, then the panel's + * per-status header and every list item with its status. */ +function todoShape(row: Element, panel: Element): string { + const pick = (from: Element, name: string): Element[] => + [...from.querySelectorAll('*')].filter(el => hasClass(el, name)) + const first = (from: Element, name: string): string => + pick(from, name)[0]?.textContent?.trim() ?? '<absent>' + const items = [...panel.querySelectorAll('[data-status]')] + .map(item => `item=${item.getAttribute('data-status')} ${item.textContent?.trim() ?? ''}`) + return [ + `row=${row.getAttribute('data-tool')}`, + `title=${first(row, 'title')}`, + `summary=${first(row, 'summary')}`, + `suffix=${first(row, 'summarySuffix')}`, + `panel=${first(panel, 'progress')}`, + ...items, + ].join('\n') +} + +describe('assembled todo surfaces', () => { + it('renders the parallel plan as a row summary, a separate active count, and the dock plan strip', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + // The todo turn is the fixture's last, so wait for its keyed row rather + // than for chat content in general. + const row = await waitFor(() => { + const found = document.querySelector('[data-tool="todo_write"]') + expect(found).not.toBeNull() + return found! + }, { timeout: 10_000 }) + // The panel is the standing plan the turn's `todo/write` event feeds; it + // mounts above the composer, outside the row, and starts collapsed — its + // list only exists once expanded. + const panel = await screen.findByTestId('todo-panel', undefined, { timeout: 10_000 }) + const toggle = panel.querySelector('button[aria-expanded]') + if (toggle === null) throw new Error('the plan strip must expose its expand toggle') + if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle) + + const shape = todoShape(row, panel) + if (refreshing) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + }) +}) diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 2b33f6a83d..57149c9195 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -126,7 +126,7 @@ describe('TodoPanel', () => { it('marks every parallel active item, and counts them all in the header', () => { render(<TodoPanel todos={PARALLEL} t={t} />) fireEvent.click(screen.getByRole('button', { expanded: false })) - // The cap this branch removes made this list unreachable: three items carry + // The old unconditional cap made this list unreachable: three items carry // the in-progress glyph at once, and the header counts all three. const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status')) expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 76f129fcda..12d1f5f665 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -140,13 +140,13 @@ describe('dsh-tool-todo', () => { expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) }) - describe('allowParallelInProgress: false', () => { + describe('allowParallelInProgress', () => { const parallel = [ { content: 'run subagent a', status: 'in_progress' }, { content: 'run subagent b', status: 'in_progress' }, ] - it('rejects a call marking several items in_progress', async () => { + it('false rejects a call marking several items in_progress', async () => { const ctx = await setup(false) const agent = agentWithSession('single-active') const result = await callTodo(ctx, { todos: parallel }, { agent }) @@ -156,7 +156,7 @@ describe('dsh-tool-todo', () => { expect(agent.session.events.some(e => e.type === 'todo/write')).toBe(false) }) - it('still accepts one active item', async () => { + it('false still accepts one active item', async () => { const ctx = await setup(false) const todos: TodoItem[] = [ { content: 'run subagent a', status: 'in_progress' }, @@ -166,7 +166,7 @@ describe('dsh-tool-todo', () => { expect(result.isError).toBe(false) }) - it('an explicit true accepts a parallel write', async () => { + it('true accepts the very list false rejects', async () => { const ctx = await setup(true) const result = await callTodo(ctx, { todos: parallel }) expect(result.isError).toBe(false) From 230752aaa2e98ff42afd607fc2d57bc74b53403b Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 12:48:55 +0800 Subject: [PATCH 111/190] test(web): keep the assembled boot entry list module-private knip rejects it as an unused export: both snapshot files reach the graph through mountAssembledApp, never through the entry list itself. --- apps/web/tests/assembled-boot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index c2c9f49e08..0c213c4444 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -15,7 +15,7 @@ import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' import { AppWebEntry } from '@deepseek-ai/dsh-client-web' /** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */ -export const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, From f330b6ae796e22df398fea7d9ccdeb9b155d17b8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 12:51:18 +0800 Subject: [PATCH 112/190] test(web): refresh targeted provider action golden --- .../snapshots/onboarding-deepseek-config/models.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index f0177144c6..3eaef94eef 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -16,7 +16,7 @@ - list: - listitem: - text: DeepSeek - - button "编辑" + - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 - textbox "API 密钥": - /placeholder: 已配置——输入新值可替换 From bb18a13fb3f2690b313d587716ae17e98f4ae1e8 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 13:00:14 +0800 Subject: [PATCH 113/190] docs(todo): drop stale plan-strip format claims and fix the note's counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan strip header has not emitted `<done>/<total> tasks · <n> in progress` since #740 replaced it with `·`-joined per-status counts. Four sites still quoted the old string: plan-summary.ts's module JSDoc, ui-conversation's README pair, and the web-todo-display note pair. The parallel-in-progress note's regression example mixed a four-item plan with an eight-item row reading, and built-boot.snapshot.ts still called itself the only test loading the built bundles. --- .../feature/2026-07-23-web-todo-display.i18n.yaml | 4 ++-- .../feature/2026-07-23-web-todo-display.md | 2 +- .../feature/2026-07-23-web-todo-display.zh.md | 2 +- .../2026-07-26-todo-parallel-in-progress.i18n.yaml | 4 ++-- .../feature/2026-07-26-todo-parallel-in-progress.md | 2 +- .../2026-07-26-todo-parallel-in-progress.zh.md | 2 +- apps/web/tests/built-boot.snapshot.ts | 12 ++++++------ packages/client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/toolviews/plan-summary.ts | 6 +++--- 11 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 2a32b80867..a29d76697f 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: 338534d2d2eeb4b1d6df79b32f0d4ec5b6695d39 -2026-07-23-web-todo-display.zh.md: d6e3c2f56ac0251e59a434cd76c198b0434991be +2026-07-23-web-todo-display.md: 82bf6e01b61d3f9f49e7b21164d324231c0ed150 +2026-07-23-web-todo-display.zh.md: ed6c3a2d7bb913b4ab1debafb2699f6262595a9f diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 338534d2d2..82bf6e01b6 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -18,7 +18,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it ### TodoPanel: the durable list as a persistent strip -The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, using `ctx.slots.inject` with no `ConversationService` edge, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"<done>/<total> tasks · <n> in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, using `ctx.slots.inject` with no `ConversationService` edge, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. ### TodoRow: the per-call row through the keyed toolview slot diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index d6e3c2f56a..ed6c3a2d7b 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -18,7 +18,7 @@ Status: implemented ### TodoPanel:持久化列表作为一条常驻横条 -面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry` 使用 `ctx.slots.inject`,不依赖 `ConversationService`,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry` 使用 `ctx.slots.inject`,不依赖 `ConversationService`,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加以 `·` 连接的各状态计数的表头(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 ### TodoRow:经 keyed toolview slot 的逐调用行 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 9dd6946ab5..d227299170 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: b380ee86154f8436416126725a7bb486f6dc052d -2026-07-26-todo-parallel-in-progress.zh.md: f007cc5645df211adc8e391f72ad2d90666e9c0f +2026-07-26-todo-parallel-in-progress.md: 8480107920ace22b6f79b96145bb9d2103455f5a +2026-07-26-todo-parallel-in-progress.zh.md: 81d5411e374daa64c7e112d0269ea733b15a133b diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index b380ee8615..8480107920 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -37,7 +37,7 @@ The durable-log invariant deliberately does NOT follow the flag. A log written w ## The display surfaces are part of the change -Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · <one task>` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. +Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · <one task>` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index f007cc5645..81d5411e37 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -37,7 +37,7 @@ Status: implemented ## 展示面是本次改动的一部分 -解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 +解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index ef6ee9b70e..3d1536e923 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -1,10 +1,10 @@ // @vitest-environment jsdom -// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the -// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's -// ModuleLoader path (loadBundle) and proves the boot graph -// assembles — staged activation across the immediately tier and the inject -// layers, per-plugin CSS injection, and a rendered journey reaching chat -// content from the keyless FixtureApiClient transport. +// The built-bundle boot smoke: the assembled-jsdom test that owns the boot +// graph itself. Other files share the same scaffolding (assembled-boot.ts) to +// reach a surface only the built bundles expose; this one asserts that the +// graph assembles at all — staged activation across the immediately tier and +// the inject layers, per-plugin CSS injection, and a rendered journey reaching +// chat content from the keyless FixtureApiClient transport. // // Component behavior remains owned by per-package suites (SlotTestRuntime // benches over src). This smoke additionally pins the resident interaction diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6abe3037ec..ebf039c0de 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: c34a5027aa6c606b3e1704d24c542db82ed01e73 -README.zh.md: 3fdb165b868b002b2444e542a697d3f719a50c24 +README.md: e02c569bb0efe9219844d86e029f499ce406929d +README.zh.md: 884dcfccd7b1af7a674a126b14bc2c3b08b3d3b0 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c34a5027aa..e02c569bb0 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -34,7 +34,7 @@ A `grep`/`glob` call declaring the `search` render intent renders its result inl Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders. -The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` plus a `+<n>` count of the other active ones, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). When the deployment permits parallel work, several items may be `in_progress` at once, so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to `ToolRow`'s `summarySuffix`, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` plus a `+<n>` count of the other active ones, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). When the deployment permits parallel work, several items may be `in_progress` at once, so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to `ToolRow`'s `summarySuffix`, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3fdb165b86..884dcfccd7 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -34,7 +34,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 -todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 经 `toolviews/plan-summary.ts` 的 `planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,以及「其余活跃项的数量」`+<n>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。部署允许并行工作时,可以有多个条目同时处于 `in_progress`,因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。该行把数量交给 `ToolRow` 的 `summarySuffix`——共享行在被截断文本旁的不收缩位(出错的行会丢弃它,因为其折叠摘要是失败首行)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 经 `toolviews/plan-summary.ts` 的 `planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,以及「其余活跃项的数量」`+<n>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。部署允许并行工作时,可以有多个条目同时处于 `in_progress`,因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。该行把数量交给 `ToolRow` 的 `summarySuffix`——共享行在被截断文本旁的不收缩位(出错的行会丢弃它,因为其折叠摘要是失败首行)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加它自行计算的、以 `·` 连接的各状态计数(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 diff --git a/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts b/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts index 9c81f008fe..6fc37d8c28 100644 --- a/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts +++ b/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts @@ -2,9 +2,9 @@ * Pure plan derivation for the todo_write row's one-line summary. Several items * may be `in_progress` at once — parallel work runs concurrent tasks, so a * summary built from one active item would silently drop the rest. The plan - * strip header derives its own `<done>/<total> tasks · <n> in progress` counts - * inline and shares nothing with this, so this stays inside the toolviews - * domain rather than in `contract/` (the inter-domain face). + * strip header derives its own counts inline and shares nothing with this, so + * this stays inside the toolviews domain rather than in `contract/` (the + * inter-domain face). * @module */ From 099b903ac6ccd124acb653db4d528dd51f9b7c00 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 13:15:09 +0800 Subject: [PATCH 114/190] fix(web): preserve provider credential retry checkpoint --- apps/web/tests/models-settings.e2e.ts | 26 ++++++++++++++++--- .../models-settings/native-delete.expected.md | 7 +++++ .../ui-models/src/client/ProviderEditor.tsx | 7 ++--- .../client/ui-models/src/client/locales.ts | 4 +-- .../ui-models/tests/components.spec.tsx | 11 ++++++-- 5 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 apps/web/tests/snapshots/models-settings/native-delete.expected.md diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 36892f2071..9078e53ff6 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -9,9 +9,9 @@ // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can -// never shadow the derived reference. Removing that row is guarded by the -// localized, identified provider-confirmation dialog before the credential -// and settings unsets reach the wire. +// never shadow the derived reference. The deletion dialog distinguishes a +// reference-free profile from a page-managed key before the credential and +// settings unsets reach the wire. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -27,6 +27,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') +const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md') const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() @@ -88,6 +89,21 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).not.toContain('MINIMAX_CN_API_KEY') }, 60_000) + it('describes reference-free deletion without claiming a credential exists', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-delete')) + const settingsDialog = page.getByRole('dialog', { name: '设置' }) + await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click() + const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' }) + await deleteDialog.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria( + page, + '[role="dialog"][aria-label="删除 minimax-cn?"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(NATIVE_DELETE_EXPECTED, snapshot, MODE) + await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() + }, 60_000) + it('stores the key under the derived reference and keeps the route live', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) @@ -163,6 +179,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/models-settings/native-delete.expected.md b/apps/web/tests/snapshots/models-settings/native-delete.expected.md new file mode 100644 index 0000000000..6ff480db12 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/native-delete.expected.md @@ -0,0 +1,7 @@ +- dialog "删除 minimax-cn?": + - heading "删除 minimax-cn?" [level=2] + - button "关闭": + - img + - paragraph: 删除 minimax-cn 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。 + - button "取消" + - button "删除 minimax-cn" diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 46350a145d..30f5c376e0 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -133,9 +133,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState<string | undefined>(undefined) - // A settings success becomes the next retry baseline immediately. If the - // following credential write fails, retry sends only the credential instead - // of replaying the already-committed settings write with a stale revision. + // A settings success advances both retry baselines immediately. Keeping the + // derived fields in the draft prevents a pushed namespace refresh from + // turning them into deletions when the following credential write is retried. const [committedOriginal, setCommittedOriginal] = useState<unknown>( () => getPath(namespace.user, settingsPath), ) @@ -215,6 +215,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { } setCommittedOriginal(getPath(response.result.value.user, settingsPath)) setExpectedRevision(response.result.value.revision) + setDraft(next) } if (normalizedKey.length > 0) { const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey }) diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 4fc76695e4..d85a3dd964 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -10,7 +10,7 @@ export const en = { remove: 'Delete', removeProvider: 'Delete {provider}', deleteTitle: 'Delete {provider}?', - deleteDescription: 'Deleting {provider} removes its configuration. Its credential is managed elsewhere and will be kept.', + deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.', deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.', deleteConfirm: 'Delete {provider}', deleting: 'Deleting {provider}…', @@ -75,7 +75,7 @@ export const zh: typeof en = { remove: '删除', removeProvider: '删除 {provider}', deleteTitle: '删除 {provider}?', - deleteDescription: '删除 {provider} 会移除其配置;凭证由其他位置管理,将会保留。', + deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。', deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。', deleteConfirm: '删除 {provider}', deleting: '正在删除 {provider}…', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index e28df6564d..29600642a2 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -816,7 +816,7 @@ describe('ModelsSection', () => { expect(set).not.toHaveBeenCalled() }) - it('retries only the credential after settings already committed', async () => { + it('retries only the credential after refreshed settings already committed', async () => { const committed = wireNamespaces()[2]! const afterSettings: SettingsNamespaceView = { ...committed, @@ -834,7 +834,7 @@ describe('ModelsSection', () => { const set = vi.fn() .mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected')) .mockResolvedValueOnce(ok({})) - await mountSection({ mutate, set }) + const { face, controller } = await mountSection({ mutate, set }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput) @@ -842,6 +842,13 @@ describe('ModelsSection', () => { fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await screen.findByText('credential store unavailable') expect(mutate).toHaveBeenCalledOnce() + face.settings.describe.mockResolvedValue(ok({ + writable: true, + hasDocument: false, + namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace), + })) + await act(async () => { await controller.load() }) + expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) expect(mutate).toHaveBeenCalledOnce() From 2e930291f931143c870b5253b95155ebc52ea301 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 13:16:52 +0800 Subject: [PATCH 115/190] docs(note): correct todo dock entry order and projection hook The web-todo-display note still described the dock entry as registering at order: -1 and reading snapshot.todos through useSession. TodoPanel.tsx registers at order: 0 and TodoDock reads the host-computed todos projection through useProjection. --- .../implemented/feature/2026-07-23-web-todo-display.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-23-web-todo-display.md | 2 +- .../implemented/feature/2026-07-23-web-todo-display.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index a29d76697f..0ded7041c4 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: 82bf6e01b61d3f9f49e7b21164d324231c0ed150 -2026-07-23-web-todo-display.zh.md: ed6c3a2d7bb913b4ab1debafb2699f6262595a9f +2026-07-23-web-todo-display.md: 1738e8aa31d270574e22f75ee57442d6a99997ec +2026-07-23-web-todo-display.zh.md: 431d8c0783faf3c6ae12a03bfcf7c7588b199e4b diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 82bf6e01b6..1738e8aa31 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -18,7 +18,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it ### TodoPanel: the durable list as a persistent strip -The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, using `ctx.slots.inject` with no `ConversationService` edge, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, using `ctx.slots.inject` with no `ConversationService` edge, `order: 0` above the queue rows), hidden while empty, collapsible to a header of title + `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads the host-computed `todos` projection via the standard-kit `useProjection` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. ### TodoRow: the per-call row through the keyed toolview slot diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index ed6c3a2d7b..431d8c0783 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -18,7 +18,7 @@ Status: implemented ### TodoPanel:持久化列表作为一条常驻横条 -面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry` 使用 `ctx.slots.inject`,不依赖 `ConversationService`,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加以 `·` 连接的各状态计数的表头(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry` 使用 `ctx.slots.inject`,不依赖 `ConversationService`,`order: 0` 排在队列条上方),空列表时隐藏,可折叠为标题加以 `·` 连接的各状态计数的表头(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useProjection` hook 读取 host 计算的 `todos` 投影——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 ### TodoRow:经 keyed toolview slot 的逐调用行 From 5663c9f507c873cd5adfaa0b6b7d561777e26ed5 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 13:28:40 +0800 Subject: [PATCH 116/190] test(web): refresh two stale markdown aria goldens ea2fc29530 gave the assistant footer separators flanking spaces, so the accessible text reads `{{clock}} Ran for {{duration}}`. The CJK-strong and inline-code-link goldens were recorded on a base that predates it and merged without a re-record; master's push runs skip the snapshot lane, so nothing caught the drift until a branch merged both. Every other golden already carries the space. --- apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md | 2 +- .../tests/snapshots/markdown-inline-code-links/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 68a4df5603..187ab25e8c 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -40,7 +40,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 059849223c..19efa06238 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img From 60153324ce85a0d1cd5fae582dc40dbd1573ba06 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 13:31:40 +0800 Subject: [PATCH 117/190] fix(web): align the plan summary's usable-content rule with the tool planSummary treated whitespace-only content as nameable, so a rejected call whose args survive verbatim rendered a blank active clause beside a live +N. The tool's own rule is trimmed non-empty; the row now uses it. Also hoists the duplicated DSH_SNAPSHOT refresh flag out of the two assembled snapshot files into their shared assembled-boot scaffolding. --- apps/web/tests/assembled-boot.ts | 7 +++++++ apps/web/tests/search-card.snapshot.ts | 5 ++--- apps/web/tests/todo-row.snapshot.ts | 5 ++--- .../src/client/toolviews/plan-summary.ts | 12 +++++++----- .../client/ui-conversation/tests/todo-panel.spec.tsx | 8 ++++++-- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 0c213c4444..0e168ba9fe 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -124,3 +124,10 @@ export function mountAssembledApp(): void { export function hasClass(el: Element, name: string): boolean { return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) } + +/** + * Whether this run rewrites its golden instead of comparing against it, set by + * the snapshot gate's `DSH_SNAPSHOT` mode (`record` re-runs the scenarios from + * scratch, `refresh` re-derives the expected text from the existing ones). + */ +export const REFRESHING_GOLDEN = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 745b545293..626be993a6 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -18,10 +18,9 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' +import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt') -const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' installAssembledBootEnv() @@ -72,7 +71,7 @@ describe('assembled search card', () => { expect(grepRow.querySelector('[data-search]')).not.toBeNull() }, { timeout: 10_000 }) const shape = cardShape(grepRow) - if (refreshing) { + if (REFRESHING_GOLDEN) { mkdirSync(dirname(EXPECTED), { recursive: true }) writeFileSync(EXPECTED, shape) } diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index 70fd95c447..c05057dddc 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -14,10 +14,9 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' +import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt') -const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' installAssembledBootEnv() @@ -63,7 +62,7 @@ describe('assembled todo surfaces', () => { if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle) const shape = todoShape(row, panel) - if (refreshing) { + if (REFRESHING_GOLDEN) { mkdirSync(dirname(EXPECTED), { recursive: true }) writeFileSync(EXPECTED, shape) } diff --git a/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts b/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts index 6fc37d8c28..dd4fd67be1 100644 --- a/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts +++ b/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts @@ -38,17 +38,19 @@ export interface PlanSummary { * the first `in_progress` item and counts the remaining active ones, so a * parallel plan reports how many tasks are running rather than naming one and * hiding the others. `activeContent` is null when nothing is in progress, or - * when the first active item carries no usable content — model JSON may. The - * row then renders the counts alone rather than falling back to the generic - * tool summary: the counts are already known to be good, and the active-item - * clause is the only part an unusable name costs. + * when the first active item's content is missing, mistyped, or blank once + * trimmed — the tool's own rule for usable content, applied here because a + * rejected call keeps its args verbatim. The row then renders the counts alone + * rather than falling back to the generic tool summary: the counts are already + * known to be good, and the active-item clause is the only part an unusable + * name costs. * @param todos - the whole list, in model order. * @returns the done/total counts and the two summary halves. */ export function planSummary(todos: readonly PlanItemLike[]): PlanSummary { const active = todos.filter(t => t.status === 'in_progress') const first = active[0]?.content - const named = typeof first === 'string' && first !== '' + const named = typeof first === 'string' && first.trim() !== '' return { done: todos.filter(t => t.status === 'completed').length, total: todos.length, diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 57149c9195..3ab95168a7 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -62,12 +62,16 @@ describe('planSummary', () => { }) it('has no hint when the first active item carries no usable content (model JSON)', () => { - // Unvalidated args: a missing, mistyped, or empty content yields no hint — - // and no orphan count, even with a second active item to count. + // Unvalidated args: a missing, mistyped, empty, or whitespace-only content + // yields no hint — and no orphan count, even with a second active item to + // count. Whitespace-only is the tool's own rejection rule (trimmed + // non-empty), and a rejected call keeps its args verbatim. expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }])) .toMatchObject({ activeContent: null, activeExtra: 0 }) expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull() expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull() + expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }])) + .toMatchObject({ activeContent: null, activeExtra: 0 }) }) it('is empty-safe', () => { From 391541207092047370c291901b4467989cf88d94 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 13:43:12 +0800 Subject: [PATCH 118/190] docs(web): say which active item activeContent names The field JSDoc read as if it searched the active items for a usable one, which is the skip-forward behavior planSummary deliberately does not do. --- .../client/ui-conversation/src/client/toolviews/plan-summary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts b/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts index dd4fd67be1..df609e7b3f 100644 --- a/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts +++ b/packages/client/ui-conversation/src/client/toolviews/plan-summary.ts @@ -27,7 +27,7 @@ export interface PlanItemLike { export interface PlanSummary { done: number total: number - /** First `in_progress` content, or null when there is no usable one to name. */ + /** First `in_progress` content, or null when that first item is unusable. */ activeContent: string | null /** Active items beyond the first; 0 whenever there is no `activeContent` to sit beside. */ activeExtra: number From 9b01da195a915b0a271b8bc23d2f7daa929b8a69 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 13:56:39 +0800 Subject: [PATCH 119/190] fix(ui-conversation): keep the summary suffix on one line `flex: none` stops the `+n` box shrinking but not its text wrapping, so a row too narrow for title + separator + suffix broke the one-line summary in the exact case the slot exists for. Add `white-space: nowrap` and pin the declaration as CSS text, since jsdom has no layout. Also align the README's row illustration with the English rendering (the same sentence's plan-strip clause already used it), and record the two deferred review findings in the Agent Note so they survive merge. --- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 4 ++ ...2026-07-26-todo-parallel-in-progress.zh.md | 4 ++ .../client/ui-conversation/README.i18n.yaml | 2 +- packages/client/ui-conversation/README.md | 2 +- .../src/client/chat/ToolRow.module.css | 5 ++- .../tests/tool-row-styles.spec.ts | 42 +++++++++++++++++++ 7 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 packages/client/ui-conversation/tests/tool-row-styles.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index d227299170..65c10eb347 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 8480107920ace22b6f79b96145bb9d2103455f5a -2026-07-26-todo-parallel-in-progress.zh.md: 81d5411e374daa64c7e112d0269ea733b15a133b +2026-07-26-todo-parallel-in-progress.md: 558dd6dda1452515ea1f1c173941a5e51e9653ef +2026-07-26-todo-parallel-in-progress.zh.md: 8aabef3e058f7dfd02ac3dcbae0214ed930440a4 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 8480107920..558dd6dda1 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -45,6 +45,10 @@ The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first a `summarySuffix` is a slot on `ToolRow` rather than markup owned by the todo row: every toolview renders through that shared component, whose `summary` is a plain ellipsized string with no place for a fragment that must survive the clip. Sitting outside the `.summary` rule, the suffix repeats that rule's `font-size` and `line-height` — the web shell leaves body text at the browser default rather than the row's 14px, so an unstyled span renders visibly larger than the text beside it on a 24px row. An error row drops the suffix, because its collapsed summary is the failure line rather than anything derived from the call args. +## Deferred + +Two review findings are recorded here rather than fixed on this branch. The `summarySuffix` span carries no accessible name, so a screen reader reads the count without its noun (`… 实现 fixture 样本 +1`); naming it introduces localized copy with its own test contract, which belongs to an accessibility pass over the whole `ToolRow` summary line rather than to one row. And when the *first* active item's content is unusable — missing, mistyped, or blank once trimmed — the row drops the active clause and the count with it, so a parallel plan renders as bare counts; skipping forward to the first usable active item was rejected because call args are an explicitly unvalidated boundary where model order is the only ordering the row can honour, and dropping the unusable clause alone keeps the `done`/`total` counts, which are trustworthy regardless. + ## Consequences A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas.<n>.expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index 81d5411e37..8aabef3e05 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -45,6 +45,10 @@ Status: implemented `summarySuffix` 是 `ToolRow` 上的槽位,而不是 todo 工具行自有的标记:每个 toolview 都经由这个共享组件渲染,而它的 `summary` 是一个会被省略号截断的普通字符串,容不下一个必须挺过截断的片段。该后缀落在 `.summary` 规则之外,因此重复了该规则的 `font-size` 与 `line-height`——Web 外壳把正文字号留在浏览器默认值而非该行的 14px,所以未加样式的 span 会明显大于同一 24px 行内与之并列的文本。错误行会丢弃该后缀,因为它折叠态的摘要是失败行,而非任何由调用 args 推导出的内容。 +## 暂缓项 + +两条 review 结论在此记录而非在本分支修复。`summarySuffix` 这个 span 没有无障碍名称,屏幕阅读器读出的数量缺少它所修饰的名词(`… 实现 fixture 样本 +1`);为它命名会引入带自身测试契约的本地化文案,这属于对整条 `ToolRow` 摘要行做的无障碍专项,而不属于某一行。以及,当*第一个*活跃条目的 content 不可用时——缺失、类型不对、或 trim 后为空——行会连同数量一起丢掉活跃子句,于是并行计划渲染成裸计数;向后跳到第一个可用活跃条目的方案被否决,因为调用 args 是一处明确未经校验的边界,模型给出的顺序是该行唯一能遵循的顺序,而只丢掉不可用的那个子句可以保住 `done`/`total` 计数——这两个数无论如何都是可信的。 + ## 后果 现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 171e6967bc..0e49265ca1 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: b4cbfc9c9fde730ff44fc63bd87194b37d518919 +README.md: 0c9ea8af211826e86412504674b0ca07536c822c README.zh.md: f11fb1133655ce8ee73507bc926a38e1e5274e4e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b4cbfc9c9f..0c9ea8af21 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -34,7 +34,7 @@ A `grep`/`glob` call declaring the `search` render intent renders its result inl Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders. -The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` plus a `+<n>` count of the other active ones, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). When the deployment permits parallel work, several items may be `in_progress` at once, so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to `ToolRow`'s `summarySuffix`, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> completed · <active item>` plus a `+<n>` count of the other active ones, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). When the deployment permits parallel work, several items may be `in_progress` at once, so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to `ToolRow`'s `summarySuffix`, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index c62949b0f3..78dfdca877 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -91,10 +91,13 @@ /* Trailing summary fragment kept out of .summary's ellipsis, for a count whose whole value is that it survives a narrow row (the todo row's parallel-active - `+n`). Repeats .summary's type because it sits beside that text. */ + `+n`). Repeats .summary's type because it sits beside that text, and its + `nowrap` too: `flex: none` stops the box shrinking but not the text wrapping, + which would break the one-line row in the narrow case the slot exists for. */ .summarySuffix { flex: none; margin-left: 4px; + white-space: nowrap; font-size: 14px; line-height: 24px; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-conversation/tests/tool-row-styles.spec.ts b/packages/client/ui-conversation/tests/tool-row-styles.spec.ts new file mode 100644 index 0000000000..50ac6b7886 --- /dev/null +++ b/packages/client/ui-conversation/tests/tool-row-styles.spec.ts @@ -0,0 +1,42 @@ +/** + * The one-line contract of the ToolRow summary line as CSS text. jsdom has no + * layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans + * exist but not whether a narrow row still fits on one line; these read the + * declarations the layout depends on. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.module.css', import.meta.url)), 'utf8') +/** Declarations only: the sheet's prose names the properties it explains. */ +const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ') + +function declarations(selector: string): string[] { + const rule = new RegExp(`\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText) + if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('ToolRow.module.css summary line', () => { + it('keeps the summary suffix on one line and unshrunk', () => { + // `flex: none` stops the box shrinking, not the text wrapping: without + // `nowrap`, a row too narrow for title + separator + suffix wraps the `+n` + // onto a second line — the exact case the slot exists to survive. + expect(declarations('.summarySuffix')).toEqual(expect.arrayContaining([ + 'flex: none', + 'white-space: nowrap', + ])) + }) + + it('leaves the truncation to the summary text alone', () => { + // The suffix must never ellipsize: a clipped count reads as a smaller + // number rather than as missing information. + expect(declarations('.summary')).toEqual(expect.arrayContaining([ + 'overflow: hidden', + 'text-overflow: ellipsis', + 'white-space: nowrap', + ])) + expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis'])) + }) +}) From bb920b32e004926157b3d4e842d9d419b2b1f5ad Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 13:59:05 +0800 Subject: [PATCH 120/190] feat(web): add dedicated skill tool row --- .../2026-08-06-web-skill-tool-row.i18n.yaml | 6 + .../feature/2026-08-06-web-skill-tool-row.md | 29 +++ .../2026-08-06-web-skill-tool-row.zh.md | 29 +++ apps/web/tests/skill-tool-row.e2e.ts | 80 +++++++ .../snapshots/skill-tool-row/ui.expected.md | 48 ++++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/src/icons/index.tsx | 14 ++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 4 + packages/client/ui-skill/README.zh.md | 4 + packages/client/ui-skill/package.json | 20 +- .../ui-skill/src/client/SkillRow.module.css | 212 ++++++++++++++++++ .../client/ui-skill/src/client/SkillRow.tsx | 174 ++++++++++++++ packages/client/ui-skill/src/client/index.ts | 26 ++- .../client/ui-skill/src/client/locales.ts | 23 ++ .../ui-skill/tests/browser-plugin.spec.ts | 69 +++++- .../client/ui-skill/tests/skill-row.spec.tsx | 152 +++++++++++++ packages/client/ui-skill/tsconfig.json | 9 + pnpm-lock.yaml | 24 ++ tsconfig.host.json | 1 + 21 files changed, 921 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md create mode 100644 apps/web/tests/skill-tool-row.e2e.ts create mode 100644 apps/web/tests/snapshots/skill-tool-row/ui.expected.md create mode 100644 packages/client/ui-skill/src/client/SkillRow.module.css create mode 100644 packages/client/ui-skill/src/client/SkillRow.tsx create mode 100644 packages/client/ui-skill/src/client/locales.ts create mode 100644 packages/client/ui-skill/tests/skill-row.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml new file mode 100644 index 0000000000..8186444a8d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.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-06-web-skill-tool-row.md +2026-08-06-web-skill-tool-row.md: b1d76c411d7ccc839616ddcce9fee18716489bf5 +2026-08-06-web-skill-tool-row.zh.md: c16a9b84d75c641b0fdd8778ff56c331c2c81546 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md new file mode 100644 index 0000000000..b1d76c411d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -0,0 +1,29 @@ +# Agent Note: Web skill tool row + +Status: implemented + +English | [中文](2026-08-06-web-skill-tool-row.zh.md) + +## Problem + +The Web transcript renders `skill` calls through the generic fallback row, so a loaded instruction set looks like an unknown tool call even though Skill is a first-class product concept. The generic row also exposes the JSON argument envelope beside the result, adding noise around the one identity users need: the loaded skill name. + +## Decision + +`ui-skill` registers a component under the existing `conversation.chat.toolview` keyed slot with key `skill`. The component owns its row chrome from the public `ToolRowProps` contract, matching the independent registrant posture used by the Bash sample instead of importing conversation-private components. + +The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. + +The row derives every visible value from the logged call/result slice. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. + +## Alternatives considered + +- Keep the generic tool row and add only a `skill` color selector in `ui-conversation`. This leaves the redundant input envelope and generic expanded body in place, and makes the conversation package own a domain-specific visual rule. +- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool without changing the wire contract, so a new cross-boundary presentation value adds protocol and snapshot surface without enabling another consumer. +- Export the conversation package's private `ToolRow` component for reuse. Client packages intentionally expose contracts rather than cross-package components; exporting it would couple independent feature packages to conversation implementation details. + +## Consequences + +`ui-skill` now depends on the public conversation toolview contract, locale and primitive packages, and React in addition to its reference-source dependencies. It owns a small copy of the disclosure-row chrome, so future global interaction changes must update this registrant alongside the Bash sample and conversation rows. + +Cold replay stays deterministic when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md new file mode 100644 index 0000000000..c16a9b84d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web skill 工具行 + +Status: implemented + +[English](2026-08-06-web-skill-tool-row.md) | 中文 + +## 问题 + +Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使已加载的指令集看起来像一次未知工具调用,尽管 Skill(技能)已是产品中的一等概念。通用行还会在结果旁暴露 JSON 参数的外层结构,围绕用户真正需要的唯一标识增加了噪声:已加载的 skill 名称。 + +## 决策 + +`ui-skill` 在现有的 `conversation.chat.toolview` 键控 slot 下注册 key 为 `skill` 的组件。该组件基于公开的 `ToolRowProps` 契约自行实现行 chrome,沿用 Bash 示例的独立注册方姿态,而不导入 conversation 私有组件。 + +收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 + +该行的所有可见值均派生自已记录的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 + +## 考虑过的替代方案 + +- 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。 +- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 无需更改协议契约即可识别该工具,因此新的跨边界呈现值只会增加协议与快照表层,却没有为其他消费方提供新能力。 +- 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。 + +## 后果 + +除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome,因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。 + +即使已安装的 skill 目录发生变化,冷回放仍具有确定性;在用户显式展开指令前,transcript 保持紧凑。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts new file mode 100644 index 0000000000..af6c941bcd --- /dev/null +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -0,0 +1,80 @@ +// Web e2e scenario: the real skill-load recording, seeded cold through the +// persistence seam, renders through ui-skill's keyed toolview without a model +// call. The disclosure proves replay-stable naming and exact durable output. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/skill-load/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'skill-tool-row-web-e2e' +const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.' + +describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, fixture, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('expands the loaded skill to its exact recorded instructions', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row')) + const call = page.locator('[data-tool="skill"]') + const row = call.getByRole('button', { name: 'Skill snapshot-skill' }) + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') + expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1) + + await row.click() + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + await call.getByText('Instructions', { exact: true }).waitFor() + const output = call.locator('pre') + await output.waitFor() + expect(await output.textContent()).toContain('<skill_content name="snapshot-skill">') + expect(await output.textContent()).toContain('Follow these snapshot-only instructions.') + expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px') + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}') + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md new file mode 100644 index 0000000000..7a51aae904 --- /dev/null +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -0,0 +1,48 @@ +- banner: + - navigation "Session hierarchy": + - button "Load the snapshot-skill skill with" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}} +- button "Copy": + - img +- 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": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection skill-catalog": + - img + - img + - text: Context injection skill-catalog +- button "Think Load the requested skill.": + - img + - img + - text: Think Load the requested skill. +- button "Skill snapshot-skill" [expanded]: + - img + - text: Skill snapshot-skill +- region "Instructions": "Instructions <skill_content name=\"snapshot-skill\"> <skill_resources> Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. </skill_resources> <skill_instructions> Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. </skill_instructions> </skill_content>" +- button "Inspect" +- button "Think The skill is loaded.": + - img + - img + - text: Think The skill is loaded. +- paragraph: DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model": + - text: Select model + - img +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index dd5fe879e7..9e395e49ad 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -62,6 +62,7 @@ "tests/startup-auto-selection.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/bash-abort-row.e2e.ts", + "tests/skill-tool-row.e2e.ts", "tests/chat-scroll-fixture.ts", "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 71e647a3e9..b0b76e164b 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -750,6 +750,20 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => ( </svg> ) +/** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */ +export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M12.5113 15.4067C12.4395 15.6249 12.1308 15.6249 12.059 15.4067L11.643 14.1416C11.454 13.567 11.0033 13.1164 10.4288 12.9274L9.16369 12.5113C8.94544 12.4395 8.94544 12.1308 9.16369 12.059L10.4288 11.643C11.0033 11.454 11.454 11.0033 11.643 10.4288L12.059 9.16369C12.1308 8.94544 12.4395 8.94544 12.5113 9.16369L12.9274 10.4288C13.1164 11.0033 13.567 11.454 14.1416 11.643L15.4067 12.059C15.6249 12.1308 15.6249 12.4395 15.4067 12.5113L14.1416 12.9274C13.567 13.1164 13.1164 13.567 12.9274 14.1416L12.5113 15.4067Z" + fill="currentColor" + /> + <path + d="M9.02246 0.546878C9.9822 0.546878 10.7564 0.545403 11.374 0.612307C12.0042 0.680586 12.5515 0.826244 13.0273 1.17188C13.3052 1.37376 13.5501 1.61868 13.752 1.89649C14.0975 2.37225 14.2432 2.91984 14.3115 3.54981C14.3784 4.16727 14.377 4.94206 14.377 5.90137V8.51367C13.9611 8.29533 13.5071 8.13985 13.0273 8.06055V5.90137C13.0273 4.9121 13.0259 4.22322 12.9688 3.69532C12.9129 3.18044 12.8098 2.89782 12.6592 2.69043C12.5406 2.52724 12.3966 2.38326 12.2334 2.26465C12.026 2.11404 11.7437 2.0109 11.2285 1.95508C10.7005 1.89789 10.0122 1.89649 9.02246 1.89649H6.55371C5.56395 1.89649 4.87569 1.89787 4.34766 1.95508C3.83242 2.01092 3.55022 2.11398 3.34278 2.26465C3.17953 2.38329 3.03564 2.52719 2.91699 2.69043C2.76642 2.89782 2.66325 3.18042 2.60742 3.69532C2.55027 4.22322 2.54883 4.9121 2.54883 5.90137V10.0986C2.54883 11.0878 2.55031 11.7768 2.60742 12.3047C2.66326 12.8196 2.76642 13.1032 2.91699 13.3105C3.03558 13.4736 3.17966 13.6178 3.34278 13.7363C3.5502 13.8869 3.83265 13.9901 4.34766 14.0459C4.87568 14.1031 5.56398 14.1035 6.55371 14.1035H8.08399C8.27443 14.6025 8.55077 15.0585 8.89551 15.4541H6.55371C5.59402 15.4541 4.81976 15.4546 4.20215 15.3877C3.57204 15.3194 3.02468 15.1738 2.54883 14.8281C2.27111 14.6263 2.02606 14.3813 1.82422 14.1035C1.47883 13.6278 1.33293 13.08 1.26465 12.4502C1.19783 11.8327 1.19922 11.0579 1.19922 10.0986V5.90137C1.19922 4.94206 1.1978 4.16727 1.26465 3.54981C1.33295 2.91984 1.47867 2.37225 1.82422 1.89649C2.02613 1.61864 2.27098 1.37379 2.54883 1.17188C3.02472 0.826181 3.57197 0.6806 4.20215 0.612307C4.81976 0.545393 5.594 0.546877 6.55371 0.546878H9.02246ZM9.19629 9.14649H4.5459V7.84571H9.19629V9.14649ZM11.0303 6.10645H4.5459V4.80567H11.0303V6.10645Z" + fill="currentColor" + /> + </svg> +) + /** ic_ds_question_outline_14 (figma extract): ring + question glyph. */ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => ( <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 9877b7df1f..92f0d3cc37 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (46 deepsuite + 17 figma extracts + the hand-authored sparkle)', () => { - expect(iconNames.length).toBe(64) + it('exports the full P-I set (46 deepsuite + 17 figma extracts + two hand-authored product glyphs)', () => { + expect(iconNames.length).toBe(65) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 059a5d8986..d23f68ee85 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-skill/README.md -README.md: fc83ae47dc83e72d60f382892aa678989902d217 -README.zh.md: e103db812d2a21f7f211bc843ec0cd31d1dc2c1e +README.md: 2280c9302dbc46cff723752f88c47940f98417d5 +README.zh.md: 0e9344ff63139f77461b02b48e18b0e94e54c223 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index fc83ae47dc..2280c9302d 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -8,6 +8,10 @@ A failed `skill.list` throws from `candidates`, which the slash shell logs and f The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect. +## Skill tool row + +The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, never from the current catalog, so cold replay remains stable even when installed skills or their descriptions change. + ## Model Experience ### Skill reference text in the user prompt diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index e103db812d..0e9344ff63 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -8,6 +8,10 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc `/client` 导出表层只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。 +## skill 工具行 + +浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,冷回放仍保持稳定。 + ## 模型体验 ### 用户提示词中的 skill 引用文本 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 20d61cdb53..c9d2dd4ed8 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", - "description": "Skill reference source: '/' menu candidates from skill.list, inserts <skill>name</skill> references", + "description": "Web skill references and the dedicated skill tool row", "version": "0.0.1", "private": true, "type": "module", @@ -25,6 +25,8 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-slash" ], "platform": "web" @@ -36,19 +38,31 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-skill/src/client/SkillRow.module.css b/packages/client/ui-skill/src/client/SkillRow.module.css new file mode 100644 index 0000000000..05c3388aa9 --- /dev/null +++ b/packages/client/ui-skill/src/client/SkillRow.module.css @@ -0,0 +1,212 @@ +/* Skill toolview: Bash-matched summary row plus a bounded instructions disclosure. */ + +.card { + display: flex; + flex-direction: column; +} + +.row { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +.row[data-expandable] { + cursor: pointer; +} + +.card[data-state='running'] .row::after { + content: ''; + position: absolute; + inset: 0 auto 0 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-skill-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-skill-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + position: relative; + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.chevron { + color: var(--dsw-alias-label-secondary); +} + +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.row:hover .iconIdle { + opacity: 0; +} + +.row:hover .chevronHover { + opacity: 1; +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.separator { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +.errorSummary { + color: var(--dsw-alias-state-error-primary); +} + +.bodyWrap { + display: flex; + flex-direction: column; +} + +.instructionsCard { + display: flex; + flex-direction: column; + max-height: 260px; + margin: 4px 0 4px 4px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); +} + +.instructionsHeader { + flex: none; + padding: 8px 12px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-markdown-code-block-banner); + font-size: 11px; + font-weight: 500; + line-height: 16px; + color: var(--dsw-alias-label-caption); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.instructions { + min-height: 0; + margin: 0; + padding: 10px 12px 12px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-markdown-code-block-small); + color: var(--dsw-alias-label-secondary); +} + +.instructions[data-error] { + color: var(--dsw-alias-state-error-primary); +} + +.instructions::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +.instructions::-webkit-scrollbar-track { + margin: 6px 0; +} + +.inspectButton { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 4px; + margin: 4px 0 2px 4px; + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} + +.card:hover .inspectButton, +.inspectButton:focus-visible { + opacity: 1; +} + +.inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + .card[data-state='running'] .row::after { + animation: none; + display: none; + } + + .iconIdle, + .chevronHover, + .inspectButton { + transition: none; + } +} diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx new file mode 100644 index 0000000000..c847678e4a --- /dev/null +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -0,0 +1,174 @@ +// Skill toolview registrant: a domain-owned row over the keyed toolview hole. +// The compact accent row keeps loaded instructions scannable in the transcript; +// the exact durable tool output remains available in a bounded disclosure card. + +import { useState, type KeyboardEvent, type ReactNode } from 'react' +import { + IconChevronDownOutline14, IconSkillOutline16, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import css from './SkillRow.module.css' + +/** Skill row lifecycle derived solely from the durable call slice. */ +type SkillRowState = 'running' | 'ok' | 'error' | 'stopped' + +/** Full row props: the toolview runtime share plus this package's locale seat. */ +type SkillRowProps = ToolRowProps & PropsLocale<'skill'> + +/** Compact, replay-stable view model for the dedicated row. */ +interface SkillRowModel { + readonly name: string + readonly output: string | null + readonly errorSummary: string | null + readonly state: SkillRowState +} + +/** First physical line for the collapsed error summary and malformed-args fallback. */ +function firstLine(text: string): string { + const newline = text.indexOf('\n') + return newline === -1 ? text : text.slice(0, newline) +} + +/** Skill names are the only call argument the compact row presents. */ +function skillName(argsRaw: string, callId: string): string { + try { + const parsed = JSON.parse(argsRaw) as unknown + if (typeof parsed === 'object' && parsed !== null) { + const name = (parsed as Record<string, unknown>).name + if (typeof name === 'string' && name !== '') return firstLine(name) + } + } catch { + // Streaming can expose a truncated JSON prefix; its first line is still + // more useful than replacing the call with an unrelated catalog lookup. + } + return argsRaw === '' ? callId : firstLine(argsRaw) +} + +/** Flatten the durable result exactly like the generic row's text fallback. */ +function resultText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + parts.push(item.type === 'text' ? item.text : JSON.stringify(item, null, 2)) + } + if (parts.length === 0 && block.error !== undefined) { + parts.push(`${block.error.name}: ${block.error.code}`) + } + return parts.join('\n') || null +} + +/** Derive display state without consulting the live skill catalog. */ +function skillRowModel(block: ToolRowProps['block']): SkillRowModel { + const settled = 'kind' in block + const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? '' + const state: SkillRowState = !settled + ? 'running' + : block.error?.code === 'interrupted' + ? 'stopped' + : block.isError ? 'error' : 'ok' + const output = resultText(block) + return { + name: skillName(argsRaw, block.callId), + output, + errorSummary: state === 'error' && output !== null ? firstLine(output) : null, + state, + } +} + +/** State substitution for the collapsed leading slot. */ +function leadingFor(state: SkillRowState): ReactNode { + switch (state) { + case 'error': return <StateDot state="error" /> + case 'stopped': return <StateDot state="warning" /> + default: return <IconSkillOutline16 /> + } +} + +/** Visually hidden state copy for the colour-only lifecycle cues. */ +function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null { + switch (state) { + case 'running': return t('row.running') + case 'error': return t('row.failed') + case 'stopped': return t('row.stopped') + default: return null + } +} + +/** Inspect affordance glyph shared with the transcript's other tool rows. */ +function IconInspect() { + return ( + <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> + <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> + </svg> + ) +} + +/** + * Render one `skill` tool call as an accent summary and instructions disclosure. + * @param props - keyed toolview payload plus the skill locale seat. + * @returns the dedicated skill row. + */ +export function SkillRow({ block, inspect, t }: SkillRowProps) { + const model = skillRowModel(block) + const [expanded, setExpanded] = useState(false) + const expandable = model.output !== null + const open = expanded && expandable + const status = stateStatus(model.state, t) + const summary = model.errorSummary ?? model.name + const ariaLabel = status === null ? `Skill ${summary}` : `${status} Skill ${summary}` + const toggleExpand = (): void => { + setExpanded(value => !value) + } + const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>): void => { + if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + toggleExpand() + } + const leading = open + ? <IconChevronDownOutline14 className={css.chevron} /> + : expandable + ? ( + <> + <span className={css.iconIdle}>{leadingFor(model.state)}</span> + <IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} /> + </> + ) + : leadingFor(model.state) + return ( + <div className={css.card} data-tool="skill" data-state={model.state}> + <div + className={css.row} + data-expandable={expandable || undefined} + role={expandable ? 'button' : undefined} + tabIndex={expandable ? 0 : undefined} + aria-expanded={expandable ? open : undefined} + aria-label={expandable ? ariaLabel : undefined} + onClick={expandable ? toggleExpand : undefined} + onKeyDown={expandable ? toggleFromKeyboard : undefined} + > + <span className={css.leading}>{leading}</span> + {status !== null ? <span className={css.visuallyHidden}>{status}</span> : null} + <span className={css.title}>Skill</span> + <span className={css.separator} aria-hidden /> + <span className={model.errorSummary === null ? css.summary : `${css.summary} ${css.errorSummary}`}> + {summary} + </span> + </div> + {open ? ( + <div className={css.bodyWrap}> + <section className={css.instructionsCard} aria-label={t('row.instructions')}> + <div className={css.instructionsHeader}>{t('row.instructions')}</div> + <pre className={css.instructions} data-error={model.state === 'error' || undefined}>{model.output}</pre> + </section> + {inspect !== undefined ? ( + <button type="button" className={css.inspectButton} onClick={inspect}> + <IconInspect /> + Inspect + </button> + ) : null} + </div> + ) : null} + </div> + ) +} diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index c23f15b770..9631125801 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -19,10 +19,24 @@ * not kill the prewarm other consumers will hit, so it carries its own * abort (fired only on invalidation/teardown) while a candidates caller * with an aborted signal just returns early. + * + * This browser half also owns the `skill` keyed toolview: a replay-stable + * accent row derived only from each logged call/result slice. */ import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { SkillRow } from './SkillRow.tsx' +import { en, NS, zh, type SkillKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The dedicated skill tool row's copy. */ + skill: SkillKey + } +} /** One session's catalog fetch: the shared promise plus its own abort handle. */ interface CatalogFetch { @@ -32,14 +46,20 @@ interface CatalogFetch { settled?: readonly SkillEntry[] } -/** Required services: slash registry, routed sessions, and the wire face. */ -export const inject = ['slash', 'connection', 'sessions'] +/** Required services: reference source faces plus the tool-row and locale registries. */ +export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale'] /** - * Client plugin body: register the '/' skill source over the root wire face. + * Client plugin body: register the '/' source, dictionaries, and keyed tool row. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries') + ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register( + { name: 'conversation.chat.toolview', key: 'skill', locale: NS }, + SkillRow, + )) + const skills = (ctx.get('connection') as ConnectionHandle).api.skills const sessions = ctx.get('sessions') as ISessions // Session-keyed catalog cache; single-flight per key. Plugin-closure state: diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts new file mode 100644 index 0000000000..53746397bc --- /dev/null +++ b/packages/client/ui-skill/src/client/locales.ts @@ -0,0 +1,23 @@ +/** `skill` namespace dictionaries for the dedicated tool row. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'skill' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'row.running': '正在加载 skill', + 'row.failed': 'skill 加载失败', + 'row.stopped': 'skill 加载已中止', + 'row.instructions': '说明', +} satisfies Record<string, string> + +/** The skill namespace key union. */ +export type SkillKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'row.running': 'Loading skill', + 'row.failed': 'Skill load failed', + 'row.stopped': 'Skill load stopped', + 'row.instructions': 'Instructions', +} satisfies Record<SkillKey, string> diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 72a7d6f7a6..3febb36efb 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -1,5 +1,6 @@ /** - * ui-skill browser half: source registration (duplicate-name proof) + + * ui-skill browser half: source and keyed toolview registration + + * locale dictionaries + source duplicate-name proof + * fiber-teardown removal (HMR safety) against the real SlashService, then * the source behavior contract driven directly on the captured source with * real ClientSessionContext projections — sessionId addressing, the @@ -16,6 +17,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import { apply, inject } from '../src/client/index.ts' +import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' type SkillRow = { name: string; description: string; whenToUse?: string } type ListResult = @@ -23,6 +25,38 @@ type ListResult = | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> +interface PresentationRegistration { + name: string + key?: string + locale?: string +} + +interface PresentationCapture { + registration?: PresentationRegistration + component?: unknown + dictionaries: Array<{ namespace: string; dictionaries: unknown }> +} + +/** Provide the presentation registries and capture the plugin's registrations. */ +function providePresentation(ctx: Context): PresentationCapture { + const capture: PresentationCapture = { dictionaries: [] } + ctx.provide('locale', { + register(namespace: string, dictionaries: unknown) { + capture.dictionaries.push({ namespace, dictionaries }) + return () => {} + }, + }) + ctx.provide('slots', { + inject(_name: string, factory: () => unknown) { factory() }, + register(registration: PresentationRegistration, component: unknown) { + capture.registration = registration + capture.component = component + return () => {} + }, + }) + return capture +} + /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ async function bench(list: ListFn, addressed?: SessionId) { const ctx = new Context() @@ -34,6 +68,7 @@ async function bench(list: ListFn, addressed?: SessionId) { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) + providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() return { ctx, source: captured! } } @@ -65,7 +100,36 @@ const req = (query: string, signal?: AbortSignal) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'connection', 'sessions']) + expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale']) + }) + + it('registers the dedicated skill row and its locale dictionaries', async () => { + const ctx = new Context() + ctx.provide('slash', { registerSource: () => () => {} }) + ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) + ctx.provide('sessions', { subagentAddress: () => undefined }) + const presentation = providePresentation(ctx) + await ctx.plugin({ inject: [...inject], apply }).await() + expect(presentation.registration).toEqual({ + name: 'conversation.chat.toolview', key: 'skill', locale: 'skill', + }) + expect(presentation.component).toBe(SkillToolRow) + expect(presentation.dictionaries).toEqual([{ + namespace: 'skill', dictionaries: { + zh: { + 'row.running': '正在加载 skill', + 'row.failed': 'skill 加载失败', + 'row.stopped': 'skill 加载已中止', + 'row.instructions': '说明', + }, + en: { + 'row.running': 'Loading skill', + 'row.failed': 'Skill load failed', + 'row.stopped': 'Skill load stopped', + 'row.instructions': 'Instructions', + }, + }, + }]) }) it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => { @@ -74,6 +138,7 @@ describe('apply', () => { ctx.provide('sessions', {}) await ctx.plugin(SlashService).await() ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) + providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.spec.tsx new file mode 100644 index 0000000000..2dacf0a036 --- /dev/null +++ b/packages/client/ui-skill/tests/skill-row.spec.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +// Dedicated skill tool row: replay-stable naming, lifecycle states, disclosure, +// keyboard operation, exact output, and the trajectory Inspect handoff. + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { SkillRow } from '../src/client/SkillRow.tsx' +import { zh } from '../src/client/locales.ts' + +type SkillRowProps = Parameters<typeof SkillRow>[0] + +const t: SkillRowProps['t'] = makeTranslate(zh, commonZh) + +afterEach(cleanup) + +function settled(over: Partial<ToolResultNode> = {}): ToolResultNode { + return { + kind: 'tool-result', + seq: 3, + time: 3_000, + callId: 'call-skill', + call: { name: 'skill', argsRaw: '{"name":"dsh-manage-issues"}' }, + callTime: 2_000, + content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }], + isError: false, + callView: null, + resultView: null, + ...over, + } +} + +function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall { + return { + callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, + } +} + +function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowProps { + return { + callId: block.callId, + toolName: 'skill', + block, + openFile: vi.fn(), + inspect, + t, + } as unknown as SkillRowProps +} + +describe('SkillRow', () => { + it('renders a compact Bash-shaped summary and discloses the exact instructions', () => { + const inspect = vi.fn() + const view = render(<SkillRow {...props(settled(), inspect)} />) + const row = screen.getByRole('button', { name: 'Skill dsh-manage-issues' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok') + expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('16') + expect(screen.queryByLabelText('说明')).toBeNull() + + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('true') + const card = screen.getByLabelText('说明') + expect(card.textContent).toBe('说明Follow the issue workflow.\nKeep project fields in sync.') + expect(view.container.textContent).not.toContain('{"name":"dsh-manage-issues"}') + fireEvent.click(screen.getByRole('button', { name: 'Inspect' })) + expect(inspect).toHaveBeenCalledTimes(1) + + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('false') + }) + + it('supports Enter and Space while ignoring unrelated keys', () => { + render(<SkillRow {...props(settled())} />) + const row = screen.getByRole('button') + fireEvent.keyDown(row, { key: 'Escape' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(row.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(row, { key: ' ' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + }) + + it('keeps a running call compact and announces its state', () => { + const view = render(<SkillRow {...props(running())} />) + const row = view.container.querySelector('[data-tool="skill"] > div')! + expect(row.getAttribute('role')).toBeNull() + expect(view.container.textContent).toContain('正在加载 skill') + expect(view.container.textContent).toContain('dsh-manage-issues') + expect(view.container.querySelector('svg [fill="currentColor"]')).not.toBeNull() + }) + + it('uses the first failure line in the summary and exposes the full error', () => { + const view = render(<SkillRow {...props(settled({ + content: [{ type: 'text', text: 'SkillError: missing resource\nCheck SKILL.md.' }], + isError: true, + error: { name: 'SkillError', code: 'missing' }, + }))} />) + const row = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing resource' }) + expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') + expect(row.textContent).not.toContain('Check SKILL.md.') + fireEvent.click(row) + const output = view.container.querySelector('pre')! + expect(output.textContent).toBe('SkillError: missing resource\nCheck SKILL.md.') + expect(output.getAttribute('data-error')).toBe('true') + }) + + it('renders stopped, structured, and structured-error durable outcomes', () => { + const stoppedView = render(<SkillRow {...props(settled({ + error: { name: 'InterruptedError', code: 'interrupted' }, + }))} />) + expect(stoppedView.container.textContent).toContain('skill 加载已中止') + expect(stoppedView.container.querySelector('[data-state="warning"]')).not.toBeNull() + cleanup() + + const structuredView = render(<SkillRow {...props(settled({ + content: [{ type: 'reasoning', text: 'structured instruction note' }], + }))} />) + fireEvent.click(screen.getByRole('button')) + expect(structuredView.container.textContent).toContain('"type": "reasoning"') + cleanup() + + render(<SkillRow {...props(settled({ + content: [], + isError: true, + error: { name: 'SkillError', code: 'missing' }, + }))} />) + const errorRow = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing' }) + fireEvent.click(errorRow) + expect(screen.getAllByText('SkillError: missing')).toHaveLength(2) + }) + + it('falls back to durable args or call id when the skill name is unavailable', () => { + const invalid = render(<SkillRow {...props(running('{"name":\n'))} />) + expect(invalid.container.textContent).toContain('{"name":') + cleanup() + + const scalar = render(<SkillRow {...props(running('"raw-name"'))} />) + expect(scalar.container.textContent).toContain('"raw-name"') + cleanup() + + const emptyName = render(<SkillRow {...props(running('{"name":""}'))} />) + expect(emptyName.container.textContent).toContain('{"name":""}') + cleanup() + + const blank = render(<SkillRow {...props(settled({ call: null, content: [] }))} />) + expect(blank.container.textContent).toContain('call-skill') + expect(blank.container.querySelector('[role="button"]')).toBeNull() + expect(blank.container.textContent).not.toContain('正在加载 skill') + }) +}) diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 318a44906a..f83486aa36 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -14,9 +14,18 @@ { "path": "../connection" }, + { + "path": "../locale" + }, { "path": "../runtime" }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34c8d60cf9..09d0384667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1942,9 +1942,21 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../ui-slash @@ -1954,9 +1966,21 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-slash: dependencies: diff --git a/tsconfig.host.json b/tsconfig.host.json index 4fcf71b680..1a0f4698f7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", + "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", From 90a1c87276e72200e0787adf6fbbc93ab49a0b98 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Thu, 6 Aug 2026 14:12:46 +0800 Subject: [PATCH 121/190] docs(notes): drop the TUI clauses the package removal made stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ed30088adb deleted packages/ui/tui, so the two present-tense clauses naming it in notes this branch already rewrites no longer describe HEAD. The operative claim — the surfaces beyond the web row needed no change — stands without naming the package. Pair hashes re-recorded. Also anchor tool-row-styles.spec.ts's rule lookup at a rule boundary, so a compound selector landing above a base rule fails loud instead of reading the wrong declaration block. --- .../feature/2026-07-23-web-todo-display.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-23-web-todo-display.md | 2 +- .../implemented/feature/2026-07-23-web-todo-display.zh.md | 2 +- .../feature/2026-07-26-todo-parallel-in-progress.i18n.yaml | 4 ++-- .../feature/2026-07-26-todo-parallel-in-progress.md | 2 +- .../feature/2026-07-26-todo-parallel-in-progress.zh.md | 2 +- .../client/ui-conversation/tests/tool-row-styles.spec.ts | 5 ++++- 7 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 0ded7041c4..cc872a60c3 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: 1738e8aa31d270574e22f75ee57442d6a99997ec -2026-07-23-web-todo-display.zh.md: 431d8c0783faf3c6ae12a03bfcf7c7588b199e4b +2026-07-23-web-todo-display.md: 9e6e4914cd24d1db9271baa3d3fb6fdc56a9ac65 +2026-07-23-web-todo-display.zh.md: 5a5ac554b37c255a7d8c1ce821ebeb1b3f9f091f diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 1738e8aa31..9e6e4914cd 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -33,4 +33,4 @@ The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview` ## Consequences -Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The TUI panel shares the same turn-scoped lifetime (the automation-only ACP bridge deliberately omits todo presentation); the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 71) plus `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pin the full chain (row summary and state, dock panel content, collapse round-trip). `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The automation-only ACP bridge deliberately omits todo presentation; the web surfaces render the same event, adding one wire field and no new event type. That field is how cold-load reconstruction stays host-backed: the tail history page carries `todos` — the full-log standing plan (latest `todo/write` with no later `turn/start`), computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan when it still stands and the last write precedes the window; that value survives an older-page prepend, is overwritten by any later write, clears on a later `turn/start`, and resets to empty when a tail response carries no projection. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index 431d8c0783..5a5ac554b3 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -33,4 +33,4 @@ Status: implemented ## 后果 -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。TUI 面板共用同一按轮次界定的生命周期(自动化专用的 ACP 桥接刻意不做 todo 呈现);Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都会自然保持 todos 一致;fx-alpha 第 71 轮的 fixture(测试前置数据)加上 `packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定整条链(行摘要与状态、dock 面板内容、折叠往返)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。自动化专用的 ACP 桥接刻意不做 todo 呈现;Web 各面渲染同一个事件,只新增一个协议字段,不新增事件类型。这个由 host 提供的字段正是冷加载重建的依据:history 尾页附带 `todos`——全量 log 上当前有效的计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`),独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时若计划仍然有效且最后一次写入落在窗口之前,计划也照常恢复;该值跨往前翻页保留,之后的任何写入照常覆盖,更晚的 `turn/start` 会清空,而尾页响应不带投影时复位为空。 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 65c10eb347..6b0ce3f378 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 558dd6dda1452515ea1f1c173941a5e51e9653ef -2026-07-26-todo-parallel-in-progress.zh.md: 8aabef3e058f7dfd02ac3dcbae0214ed930440a4 +2026-07-26-todo-parallel-in-progress.md: 2805ef894050d1b1cffe06fce59a4984d463f8d1 +2026-07-26-todo-parallel-in-progress.zh.md: 16b32daa05b10f24eacde4cec9622b2159cdef09 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 558dd6dda1..2805ef8940 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -51,4 +51,4 @@ Two review findings are recorded here rather than fixed on this branch. The `sum ## Consequences -A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas.<n>.expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. +A todo list can now faithfully mirror parallel execution, and every surface renders several active markers at once: the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas.<n>.expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index 8aabef3e05..16b32daa05 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -51,4 +51,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 +现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 diff --git a/packages/client/ui-conversation/tests/tool-row-styles.spec.ts b/packages/client/ui-conversation/tests/tool-row-styles.spec.ts index 50ac6b7886..300266a69a 100644 --- a/packages/client/ui-conversation/tests/tool-row-styles.spec.ts +++ b/packages/client/ui-conversation/tests/tool-row-styles.spec.ts @@ -13,7 +13,10 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.modul const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ') function declarations(selector: string): string[] { - const rule = new RegExp(`\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText) + // Anchored at a rule boundary: an unanchored match would silently read a + // compound rule that merely contains the selector (`.root:hover .summarySuffix`) + // if one ever lands above the base rule. + const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText) if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`) return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) } From 1ed5dc81d249a7d391fc9c03da3898fad0706517 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 14:22:54 +0800 Subject: [PATCH 122/190] docs: refresh module graph --- docs/module-graph.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 0e2e7e0c37..369441ff51 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -452,11 +452,6 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -834,6 +829,14 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_conversation + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1149,7 +1152,6 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | @@ -1224,6 +1226,7 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `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) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | From ee0e33e10f5be46a5854935459ae029c3c9496a1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 14:31:19 +0800 Subject: [PATCH 123/190] test(web): refresh markdown snapshots --- apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md | 2 +- .../tests/snapshots/markdown-inline-code-links/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 68a4df5603..187ab25e8c 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -40,7 +40,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 059849223c..19efa06238 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}}Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img From e0e84d265a24b397663507bf8e9d97c1777a033d Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 14:40:08 +0800 Subject: [PATCH 124/190] refactor(web): deduplicate skill disclosure leading --- .../client/ui-skill/src/client/SkillRow.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index c847678e4a..be1084ec39 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -85,6 +85,19 @@ function leadingFor(state: SkillRowState): ReactNode { } } +/** Leading disclosure slot: state icon at rest, chevron on hover or while open. */ +function disclosureLeading(state: SkillRowState, open: boolean, expandable: boolean): ReactNode { + if (open) return <IconChevronDownOutline14 className={css.chevron} /> + const icon = leadingFor(state) + if (!expandable) return icon + return ( + <> + <span className={css.iconIdle}>{icon}</span> + <IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} /> + </> + ) +} + /** Visually hidden state copy for the colour-only lifecycle cues. */ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null { switch (state) { @@ -125,16 +138,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { event.preventDefault() toggleExpand() } - const leading = open - ? <IconChevronDownOutline14 className={css.chevron} /> - : expandable - ? ( - <> - <span className={css.iconIdle}>{leadingFor(model.state)}</span> - <IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} /> - </> - ) - : leadingFor(model.state) + const leading = disclosureLeading(model.state, open, expandable) return ( <div className={css.card} data-tool="skill" data-state={model.state}> <div From a7c035285b810c8287e9b2028e0731da565340c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 14:50:21 +0800 Subject: [PATCH 125/190] revert: leave markdown snapshots unchanged --- apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md | 2 +- .../tests/snapshots/markdown-inline-code-links/ui.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 187ab25e8c..68a4df5603 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -40,7 +40,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}}Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 19efa06238..059849223c 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -31,7 +31,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}}Ran for {{duration}} - textbox "Message the agent" - button "Commands": - img From 4e8067e8d52deec3d0320a9f5d4135034d74a976 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 14:57:30 +0800 Subject: [PATCH 126/190] refactor(telemetry): replay feedback sessions without buffering --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 6 +- ...07-23-session-telemetry-otel-revival.zh.md | 6 +- ...feedback-gated-session-telemetry.i18n.yaml | 4 +- ...-08-05-feedback-gated-session-telemetry.md | 12 +- ...-05-feedback-gated-session-telemetry.zh.md | 12 +- ...6-buffer-free-feedback-telemetry.i18n.yaml | 6 + ...26-08-06-buffer-free-feedback-telemetry.md | 29 +++ ...08-06-buffer-free-feedback-telemetry.zh.md | 29 +++ docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 9 +- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 8 +- .../session-telemetry-otel/README.zh.md | 8 +- .../session-telemetry-otel/src/index.ts | 19 +- .../session-telemetry-otel/tests/otel.spec.ts | 2 +- .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 12 +- .../telemetry/session-telemetry/README.zh.md | 12 +- .../session-telemetry/src/coordinator.ts | 180 ++++++++---------- .../telemetry/session-telemetry/src/index.ts | 20 +- .../session-telemetry/tests/telemetry.spec.ts | 78 +++++--- 24 files changed, 270 insertions(+), 200 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md create mode 100644 .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 3f487762d6..ecb7ca1588 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md -2026-07-23-session-telemetry-otel-revival.md: dcbff9757cbb730b66f456535fbd7ae471b6ffd1 -2026-07-23-session-telemetry-otel-revival.zh.md: c3a098041795fa92bb4e0dd421ca09be94907cb8 +2026-07-23-session-telemetry-otel-revival.md: f83128e8bf62e0718e59912c16c4e449855aaa1a +2026-07-23-session-telemetry-otel-revival.zh.md: 6f955cc9e1b44ed4a558515c4904b46a9cec585f diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index dcbff9757c..f83128e8bf 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -12,9 +12,9 @@ Every deployment that wants harness sessions in an observability stack must hand `packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: -- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. +- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: live adoption with cursor read-back and the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), buffer-free on-demand replay from the canonical log, the fixed first-chunk-per-(turn, step) projection, the live `agent/error` relay, and live dispose-time `shutdown` records. - **The `telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. -- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary. +- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary, while [buffer-free feedback replay](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix. The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. @@ -34,4 +34,4 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` withholds records until feedback releases a prefix, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` replays a canonical-log prefix when feedback is recorded, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index c3a0980417..6f955cc9e1 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -12,9 +12,9 @@ Status: implemented `packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: -- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 +- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的实时收养与逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、从权威日志进行的无缓冲按需回放、固定的每 (turn, step) 首 chunk 投影、实时 `agent/error` 转发,以及实时 dispose 时的 `shutdown` 记录。 - **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 -- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY` 与 `DISABLED` 投递模式,但未移动脱敏或后端边界。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY` 与 `DISABLED` 投递模式,但未移动脱敏或后端边界,而[无缓冲反馈回放](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -34,4 +34,4 @@ Status: implemented ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系。`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在反馈释放前暂存记录前缀,`DISABLED` 则不构造上报流水线;删除条目仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系。`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在记录反馈时回放权威日志前缀,`DISABLED` 则不构造上报流水线;删除条目仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index d12ad78728..7909316acd 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: 21a9028c603f3faaec39b2ddb8ef14644d6c84d4 -2026-08-05-feedback-gated-session-telemetry.zh.md: ea94c743b962a93a5fc64bdc2e4ed103aadecc99 +2026-08-05-feedback-gated-session-telemetry.md: 25cc17f75629f72d7351eb0537d72b700c84411f +2026-08-05-feedback-gated-session-telemetry.zh.md: b0e84e60e27fa20f66113c11db62026583a27a19 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index 21a9028c60..25cc17f756 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -13,23 +13,21 @@ Session telemetry originally has one mounted behavior: every accepted record ent `@deepseek-ai/dsh-session-telemetry-otel` exposes three uppercase `mode` values: - `FULL` is the default and preserves immediate delivery to the configured OTel pipeline. -- `FEEDBACK_ONLY` captures redacted copies in memory and releases the pending session prefix when `feedback/record` is appended. The released prefix includes the feedback event itself. Records appended after that event form another withheld prefix until another feedback event releases them. +- `FEEDBACK_ONLY` reads the canonical session log when `feedback/record` is appended and hands over the unreleased prefix through that exact event. Records appended after that boundary remain local until another feedback event. - `DISABLED` constructs no exporter, processor, or logger provider. A `feedback/record` listener prints that nothing is shared and the feedback remains local. -The generic telemetry coordinator owns the delivery distinction as `immediate` or `held`. Both paths project, clone, and run `telemetry/record` listeners at capture time. Immediate delivery sends the accepted record to the backend and advances the session's handoff cursor. Held delivery retains the accepted record per session without moving that cursor. `release(session)` submits the retained records in order, contains each backend failure independently, advances the cursor only for submitted records, and removes the released prefix. +The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. -The OTel feedback listener is registered after the coordinator's session listener. Cordis therefore gives the coordinator the feedback append first, then the OTel listener releases a prefix that already contains that event. `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`; `DISABLED` does not validate or use exporter configuration. +The OTel feedback listener passes the feedback event's sequence to `captureSession()`. `Session.append` commits the event before publishing `session/event`, so replay includes that feedback but cannot extend past its boundary. `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`; `DISABLED` does not validate or use exporter configuration. ## Alternatives considered **Open a session permanently after its first feedback.** Rejected because later work would be shared without another feedback act and the plugin would need additional open-session state. Releasing one pending prefix per feedback has the smaller state machine and the narrower sharing boundary. -**Buffer after `TelemetryCoordinator.emit()` in the OTel backend.** Rejected because the coordinator would advance its handoff cursor before a record became eligible for upload. A plugin rebuild would then lose the only retained copy and incorrectly treat the prefix as handed off. - -**Replay the canonical session log when feedback arrives.** Rejected because replay would repeat projection and redaction, exclude telemetry operation records that are not session events, and require more lifecycle state to distinguish previously released prefixes. +**Retain capture-time redacted records until feedback.** Rejected because it duplicates an unbounded session prefix even though the canonical log already owns the events. It preserves capture-time redaction policy and operational records, but those properties do not justify the memory cost for a mode defined as uploading the session log after feedback. **Use an unmounted plugin as the disabled state.** That remains the silent opt-out, but it cannot warn when feedback is recorded. The explicit disabled mode lets a deployment keep one configuration shape and communicate that the local feedback did not leave the process. ## Consequences -`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` retains deep-copied, already-redacted records in process memory until feedback or session collection; a crash before release uploads nothing from that prefix. A clean shutdown after the last feedback is part of the new withheld suffix, so feedback-only streams do not carry a reliable shutdown or crash signal. Each later feedback releases the suffix accumulated since the previous one. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. +`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index ea94c743b9..b0e84e60e2 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -13,23 +13,21 @@ Status: implemented `@deepseek-ai/dsh-session-telemetry-otel` 公开三个大写的 `mode` 值: - `FULL` 是默认值,保留向已配置 OTel 流水线的即时投递。 -- `FEEDBACK_ONLY` 在内存中捕获已脱敏副本,并在追加 `feedback/record` 时释放待处理的会话前缀。已释放前缀包含反馈事件本身。在该事件之后追加的记录会形成另一个暂存前缀,直到下一个反馈事件将其释放。 +- `FEEDBACK_ONLY` 在追加 `feedback/record` 时读取权威会话日志,并交接截至该事件的未释放前缀。该边界后追加的记录会留在本地,直到另一个反馈事件。 - `DISABLED` 不构造导出器、处理器或日志提供方。`feedback/record` 监听器会输出警告,说明什么都不会共享,且反馈仍留在本地。 -通用遥测协调器以 `immediate` 或 `held` 的形式拥有这两种投递方式。两条路径都会在捕获时进行投影、深拷贝,并运行 `telemetry/record` 监听器。即时投递把已接受记录发送到后端,并推进会话的 handoff 游标。暂存投递按会话保留已接受记录,且不移动该游标。`release(session)` 按顺序提交保留的记录,独立隔离每个后端失败,仅为已提交的记录推进游标,并移除已释放前缀。 +通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)说明了按需路径为何使用权威日志而非记录副本。 -OTel 反馈监听器在协调器的会话监听器之后注册。因此,Cordis 先将反馈追加交给协调器,再由 OTel 监听器释放已包含该事件的前缀。`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填;`DISABLED` 不校验也不使用导出器配置。 +OTel 反馈监听器把反馈事件的序列号传给 `captureSession()`。`Session.append` 在发布 `session/event` 前已提交该事件,因此回放会包含该反馈,但不会超过其边界。`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填;`DISABLED` 不校验也不使用导出器配置。 ## 考虑过的替代方案 **会话在首次反馈后永久开放。** 已否决,因为后续工作会在用户未再次提交反馈的情况下被共享,而且插件需要额外的会话开放状态。每次反馈只释放一个待处理前缀,状态机更小,共享边界也更窄。 -**在 OTel 后端的 `TelemetryCoordinator.emit()` 之后缓冲。** 已否决,因为协调器会在记录具备上传资格前推进 handoff 游标。插件重建后,唯一保留的副本会丢失,而协调器会错误地将该前缀视为已交接。 - -**反馈到达时回放权威会话日志。** 已否决,因为回放会重复执行投影与脱敏,排除不属于会话事件的遥测运维记录,且需要更多生命周期状态才能区分已释放前缀。 +**反馈前保留捕获时已脱敏记录。** 已否决,因为权威日志已拥有这些事件,该方案仍会复制无上限的会话前缀。它能保留捕获时的脱敏策略与运维记录,但对于一个定义为「反馈后上传会话日志」的模式,这些性质不足以证明该内存成本合理。 **以不挂载插件表示禁用状态。** 这仍然是静默退出方式,但无法在记录反馈时输出警告。显式禁用模式让部署方可以保持同一种配置形态,并说明本地反馈未离开进程。 ## 后果 -`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 会在进程内存中保留已深拷贝且已脱敏的记录,直到收到反馈或会话被回收;释放前发生崩溃时,该前缀不上传任何内容。上次反馈之后的干净关闭属于新的暂存后缀,因此仅反馈的流不携带可靠的关闭或崩溃信号。每个后续反馈都会释放从上一个反馈开始累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 +`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;反馈前发生崩溃时,该前缀不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml new file mode 100644 index 0000000000..9f6288ac04 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.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/simplification/2026-08-06-buffer-free-feedback-telemetry.md +2026-08-06-buffer-free-feedback-telemetry.md: 008bebdcb59f7ef4fe49f8e731aad77861368d5c +2026-08-06-buffer-free-feedback-telemetry.zh.md: 7052e075921f4470864f5ea1c4aed5cf6201becf diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md new file mode 100644 index 0000000000..008bebdcb5 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md @@ -0,0 +1,29 @@ +# Agent Note: Buffer-free feedback telemetry + +Status: implemented + +English | [中文](2026-08-06-buffer-free-feedback-telemetry.zh.md) + +## Problem + +Feedback-only telemetry must upload the session-log prefix only after recorded feedback. Retaining a deep-copied, redacted record for every projected event until that trigger duplicates the canonical session log and grows without a bound for a long-lived session that never records feedback. + +## Decision + +The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no projected records. `captureSession(session, throughSeq?)` reads the canonical session log after the handoff cursor through an optional inclusive sequence boundary, applies the fixed projection, deep-copies each accepted event, runs the current `telemetry/record` waterfall, and hands the result to the backend. + +`FEEDBACK_ONLY` invokes that method with the `feedback/record` event's sequence. The append is already committed when `session/event` listeners run, so the replay contains the feedback event and cannot include a later suffix. The existing handoff cursor distinguishes later replays without another pending-record index. + +Because on-demand capture reads only the canonical log, it emits no `agent-error` or `shutdown` operational records. Redaction is evaluated at feedback time rather than append time. The [feedback mode decision](../feature/2026-08-05-feedback-gated-session-telemetry.md) owns the public sharing behavior; this note owns its buffer-free realization. + +## Alternatives considered + +**Retain capture-time redacted records.** This preserves the exact redaction policy and operational records observed when each event occurs, but duplicates the unbounded session prefix. The mode promises feedback-triggered session-log upload, not capture-time policy snapshots or pre-feedback operational telemetry. + +**Retain session event references or sequence numbers.** Rejected because the canonical log already supplies both order and identity. A second index saves payload copies but adds lifecycle state without enabling any required behavior. + +**Write a durable pre-feedback spool.** Deferred until a deployment requires crash recovery before feedback. It adds storage, cleanup, and confidentiality policy to a mode whose intended behavior is to upload nothing when the process exits before feedback. + +## Consequences + +A no-feedback session consumes no telemetry-owned memory proportional to its event count; the canonical session log remains the only pre-feedback copy. Feedback handling performs projection, cloning, and redaction synchronously before the backend's non-blocking enqueue, so its cost scales with the unreleased prefix. A redaction-policy change before feedback affects that replay, and a crash before feedback uploads nothing. Later feedback processes only events beyond the handoff cursor. diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md new file mode 100644 index 0000000000..7052e07592 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 无缓冲反馈遥测 + +Status: implemented + +[English](2026-08-06-buffer-free-feedback-telemetry.md) | 中文 + +## 问题 + +仅反馈遥测必须只在记录反馈后上传会话日志前缀。若在触发前为每个已投影事件保留一份已深拷贝、已脱敏的记录,就会复制权威会话日志;对于长期运行但从不记录反馈的会话,这份副本会无限增长。 + +## 决策 + +遥测协调器提供 `live` 与 `on-demand` 捕获。按需捕获不注册会话、flush 或运维事件监听器,也不保留投影记录。`captureSession(session, throughSeq?)` 从 handoff 游标之后读取权威会话日志,直至可选的序列号边界(含边界),应用固定投影、深拷贝每个已接受事件、运行当前的 `telemetry/record` waterfall(瀑布式事件),并将结果交给后端。 + +`FEEDBACK_ONLY` 以 `feedback/record` 事件的序列号调用该方法。`session/event` 监听器运行时,追加已经提交,因此回放包含该反馈事件,且无法包含后续后缀。现有 handoff 游标可区分后续回放,无需另一个待处理记录索引。 + +按需捕获只读取权威日志,因此不会发出 `agent-error` 或 `shutdown` 运维记录。脱敏在反馈时而非追加时求值。[反馈模式决策](../feature/2026-08-05-feedback-gated-session-telemetry.md)规定公开的共享行为;本记录规定其无缓冲实现。 + +## 考虑过的替代方案 + +**保留捕获时的已脱敏记录。** 该方案会保留每个事件发生时观察到的确切脱敏策略与运维记录,但也会复制无上限的会话前缀。该模式承诺在反馈触发后上传会话日志,而非保留捕获时策略快照或反馈前运维遥测。 + +**保留会话事件引用或序列号。** 已否决,因为权威日志已同时提供顺序与身份。第二个索引可以省去载荷副本,但会增加生命周期状态,且无法实现任何必需行为。 + +**写入持久化的反馈前 spool。** 推迟到有部署要求反馈前的崩溃恢复时再实现。该方案会为一个预期在进程于反馈前退出时不上传任何内容的模式增加存储、清理与保密策略。 + +## 后果 + +没有反馈的会话不会消耗随事件数量增长的遥测自有内存;权威会话日志仍是反馈前的唯一副本。反馈处理会在后端非阻塞入队前同步执行投影、深拷贝与脱敏,因此其开销随未释放前缀增长。反馈前的脱敏策略变更会影响该次回放,而反馈前发生崩溃时什么都不上传。后续反馈只处理 handoff 游标之后的事件。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0c4fb632c1..a4ddedab35 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1189,7 +1189,7 @@ export type TelemetryMode = typeof TELEMETRY_MODES[number] Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:54`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:55`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d159fa0a53..a416a17d46 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -830,7 +830,7 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst ### `telemetry/record` — waterfall -Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Live capture dispatches at append time; on-demand capture dispatches while reading the canonical log. Redaction applies to the exported copy only; the canonical session log is never rewritten. ```ts cordis-catalog /** @@ -844,8 +844,9 @@ Transform one outbound record before it reaches the backend. This waterfall is t * `next()` replaces everything beneath. Dispatched synchronously on the * capture hot path inside the coordinator's containment: a throwing * listener withholds that one record (fail-closed) and never reaches the - * agent loop. Redaction applies to the exported copy only; the canonical - * session log is never rewritten. + * agent loop. Live capture dispatches at append time; on-demand capture + * dispatches while reading the canonical log. Redaction applies to the + * exported copy only; the canonical session log is never rewritten. * @param record - the candidate record, already the coordinator's own deep * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall @@ -853,7 +854,7 @@ Transform one outbound record before it reaches the backend. This waterfall is t 'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:42`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:43`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4ccc19f305..82000e7f9a 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/start` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../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/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:42`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | +| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:43`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d4061261db..2298c9b4c6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1340,7 +1340,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'telemetry/record', mode: 'waterfall', signature: '\'telemetry/record\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', - jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Live capture dispatches at append time; on-demand capture\n * dispatches while reading the canonical log. Redaction applies to the\n * exported copy only; the canonical session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', summary: 'Transform one outbound record before it reaches the backend.', }, { diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 6557557b8c..84e2447fd8 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md -README.md: fab2461477b2174bded42ed6f05ae55c7c5f697c -README.zh.md: ab0191188836e03434adbce527d31b62ead848a3 +README.md: 7fc5572614a5bdba312ba97b52606032ef8f5394 +README.zh.md: 3160b67c8225fb87d5e7be2e43453ef40496fba9 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index fab2461477..7fc5572614 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam hands records over immediately, releases them only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. +The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam follows session events live, replays the canonical log only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. ## Config @@ -21,14 +21,14 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th | `mode` | Behavior | |---|---| | `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. | -| `FEEDBACK_ONLY` | Each `feedback/record` releases the redacted, projected session prefix through that event. Later records wait for another feedback event and remain local if none arrives. | +| `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. | | `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. | `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. ## What leaves the machine -In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend. +In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). `FULL` runs redaction at append time; `FEEDBACK_ONLY` retains no telemetry copy and runs the currently mounted rules when feedback triggers canonical-log replay. Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend. ## Field mapping @@ -46,4 +46,4 @@ None; this package neither assembles nor sends a provider request. - **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. - **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory. -- **Feedback-only memory** — each session retains deep-copied, redacted projected records in memory until feedback releases them or the session becomes unreachable. There is no durable pre-feedback spool; a crash before feedback uploads nothing. +- **Feedback-time snapshot** — `FEEDBACK_ONLY` retains no telemetry-owned copy before feedback. It reads and redacts the current canonical log when feedback is recorded; a crash before feedback uploads nothing, and policy changes before feedback affect what that replay exports. diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index ab01911888..3160b67c82 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是立即交接记录、仅在记录反馈时释放记录,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 +[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是实时跟随会话事件、仅在记录反馈时回放权威日志,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 ## 配置 @@ -21,14 +21,14 @@ | `mode` | 行为 | |---|---| | `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK,包括生命周期运维记录。 | -| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会释放截至该事件的已脱敏、已投影会话前缀。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | +| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | | `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 | `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`,SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。 ## 哪些数据会离开本机 -在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。 +在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。 ## 字段映射 @@ -46,4 +46,4 @@ seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`; - **上游实验性源码树**:`@opentelemetry/sdk-logs` 仍从上游实验性(experimental)源码树发布;SDK API 的变动只会落在本包,也仅落在本包;seam 契约不动。 - **无真实 collector 覆盖**:所有测试都导出到本地 mock collector;无密钥的 Loader 组合 e2e(`tests/loader-composition.e2e.ts`)在每次运行中都覆盖协议格式(wire format)形态,而面对真实 OTLP 部署的行为(认证、TLS、限流)属于 SDK 导出器文档的职责范围。 -- **仅反馈模式的内存占用**:每个会话都会在内存中保留已深拷贝、已脱敏的投影记录,直到反馈将其释放或会话变得不可达。反馈前不存在持久化 spool;如果在反馈前崩溃,则什么都不上传。 +- **反馈时快照**:`FEEDBACK_ONLY` 在反馈前不保留遥测自有副本。记录反馈时,它读取并脱敏当前的权威日志;反馈前发生崩溃时什么都不上传,而反馈前的策略变更会影响该次回放的导出内容。 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index cb0ee71fc7..908f0f90fb 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -7,7 +7,8 @@ * boundary axiom, everything downstream of that call (batching, retry, * queueing, loss policy) is the SDK's documented behavior, configured * verbatim through the `exporter`/`processor` passthroughs. This package owns - * only whether capture is immediate, feedback-released, or disabled. + * only whether capture is live, feedback-triggered from the canonical log, or + * disabled. * * @module @deepseek-ai/dsh-session-telemetry-otel */ @@ -19,7 +20,7 @@ import type {} from '@deepseek-ai/dsh-command-feedback' import { Telemetry, TelemetryCoordinator, - type TelemetryDelivery, + type TelemetryCapture, type TelemetryRecord, type TelemetrySeverity, } from '@deepseek-ai/dsh-session-telemetry' @@ -161,13 +162,13 @@ export class TelemetryOtel extends Telemetry { }) this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) - const delivery: TelemetryDelivery = mode === 'FULL' ? 'immediate' : 'held' - const coordinator = new TelemetryCoordinator(ctx, this, delivery) + const capture: TelemetryCapture = mode === 'FULL' ? 'live' : 'on-demand' + const coordinator = new TelemetryCoordinator(ctx, this, capture) if (mode === 'FEEDBACK_ONLY') { - // The coordinator listener is registered first, so a feedback event - // enters the held prefix before this listener releases that exact prefix. + // Session.append commits before publishing `session/event`, so the + // canonical log already includes this feedback record when replay begins. ctx.on('session/event', (session, event) => { - if (event.type === 'feedback/record') coordinator.release(session) + if (event.type === 'feedback/record') coordinator.captureSession(session, event.seq) }) } } @@ -206,8 +207,8 @@ export class TelemetryOtel extends Telemetry { * quiesce. With no concurrent `forceFlush()` in the process (see above), * shutdown's internal drain is complete — everything handed to the SDK * before this call is exported before the exporter closes. In `FULL`, that - * includes dispose-time `shutdown` markers; held suffixes never reach the - * SDK. Awaited (and error-contained) by the coordinator's disposer. A + * includes dispose-time `shutdown` markers; `FEEDBACK_ONLY` creates no ops + * records. Awaited (and error-contained) by the coordinator's disposer. A * disabled backend resolves immediately. * @returns resolves when the SDK pipeline has quiesced. */ diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 18c466f7aa..9b7e4119b7 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -206,7 +206,7 @@ describe('TelemetryOtel wire', () => { expect(start?.record.severityNumber).toBe(13) }) - it('holds each session suffix until the next feedback event', async () => { + it('replays each session suffix only at the next feedback event', async () => { const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index da3a62e2fd..ee9c7288b1 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/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/telemetry/session-telemetry/README.md -README.md: d38433a728c699c7fb3cc0512bb6a2d977dd4cc6 -README.zh.md: 3a86b01321fc7dfd33d39530ee7fa38a6ee1f2dc +README.md: 67d95bcc62bbf6783f8dcd11f0236d8c926b557b +README.zh.md: 1ee0e0eb14bb06c8ac669cd417f2ee2ce46ca430 diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index d38433a728..67d95bcc62 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -2,23 +2,23 @@ English | [中文](README.zh.md) -The telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can hand each redacted record over immediately or hold a per-session prefix for an explicit release. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) and [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md). +The telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can follow live session events or replay a canonical session-log prefix on demand. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md). ## The backend contract -`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path, either at capture or held-prefix release), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `immediate` delivery or `held` delivery and calls `release(session)` at its owning trigger. +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path or during an explicit canonical-log replay), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `live` capture or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its owning trigger. ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off or hold; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). Immediate delivery hands lifecycle records over; held delivery leaves any suffix after the last release local, including its later shutdown marker. +In `live` mode the coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local. ## The redact waterfall -Every record passes the `telemetry/record` waterfall immediately after projection — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Held delivery stores only the waterfall result, so later policy removal cannot expose the original capture. Redaction applies to the outbound copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/record` waterfall immediately after projection — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Live capture runs the waterfall at append time; on-demand capture runs it while replaying the canonical log, using the rules mounted at that time. Redaction applies to the outbound copy only; the canonical session log is never rewritten. ## The handoff cursor -A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Immediate delivery advances it at capture; held delivery advances it only when `release(session)` hands that record to the backend. An unreleased prefix therefore survives a coordinator reload through deterministic re-adoption instead of disappearing with its in-memory copy. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. +A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Live capture advances it at append time; on-demand capture advances it only while `captureSession()` hands a requested prefix to the backend. An uncaptured prefix remains solely in the canonical log, so a coordinator reload adds no telemetry-owned recovery state. On replay the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. ## The fixed chunk projection @@ -40,4 +40,4 @@ None; this package neither assembles nor sends a provider request. - **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). - **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. -- **Held prefixes duplicate memory** — held delivery retains one deep-copied, redacted record per projected event until release or session collection. It adds no durable outbox and intentionally trades memory for a simple no-upload-before-trigger boundary. +- **On-demand redaction uses current state** — uncaptured events exist only in the canonical session log. A later `captureSession()` deep-copies and redacts their current values with the policy mounted at that time; there is no capture-time telemetry snapshot or durable pre-capture spool. diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index 3a86b01321..1ee0e0eb14 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -2,23 +2,23 @@ [English](README.md) | 中文 -遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。捕获侧可立即交接每条已脱敏记录,也可按会话暂存一个前缀,等待显式释放。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)与[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)。 +遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 ## 后端契约 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它会在捕获或暂存前缀释放时,于 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `immediate` 或 `held` 投递模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `release(session)`。 +`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径或显式权威日志回放期间同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`。 ## 捕获点 -协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接或暂存;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。即时投递会交接生命周期记录;暂存投递会将上次释放后的任何后缀留在本地,包括随后的 shutdown 标记。 +在 `live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。在 `on-demand` 模式中,协调器只注册 dispose effect:`captureSession()` 读取权威日志,直至可选的序列号边界(含边界);flush 提示与运维事件留在本地。 ## 脱敏 waterfall(瀑布式事件) -每条记录在投影后立即经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。暂存投递只保留 waterfall 的结果,因此后续移除策略也无法暴露捕获时的原始内容。脱敏只作用于外发副本;权威会话日志永不改写。 +每条记录在投影后立即经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。实时捕获在追加时运行 waterfall;按需捕获则在回放权威日志时使用当时挂载的规则运行 waterfall。脱敏只作用于外发副本;权威会话日志永不改写。 ## handoff 游标 -一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。即时投递在捕获时推进游标;暂存投递只有在 `release(session)` 将记录交给后端时才推进游标。因此,重建协调器后会通过确定性重新收养恢复未释放的前缀,而不会随其内存副本一同消失。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 +一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。实时捕获在追加时推进游标;按需捕获只有在 `captureSession()` 将请求的前缀交给后端时才推进游标。未捕获的前缀只留在权威日志中,因此协调器重载不会增加遥测自有的恢复状态。回放时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 ## 固定分片投影 @@ -40,4 +40,4 @@ - **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 - **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 -- **暂存前缀会重复占用内存**:暂存投递会为每个已投影事件保留一份深拷贝且已脱敏的记录,直到释放或回收会话。它不增加持久化 outbox,而是有意以内存换取简单的「触发前不上传」边界。 +- **按需脱敏使用当前状态**:未捕获的事件只存在于权威会话日志中。后续的 `captureSession()` 会使用当时挂载的策略,深拷贝并脱敏其当前值;不存在捕获时的遥测快照或持久化的捕获前 spool。 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 710e9b81f9..9e32ae0693 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -1,13 +1,15 @@ /** - * Capture coordinator: the seam's upstream half. Subscribes to the session - * firehose plus the one live-bus relay (`agent/error`), applies the fixed - * chunk projection, builds logical records, runs each through the + * Capture coordinator: the seam's upstream half. Live capture subscribes to + * the session firehose plus the one live-bus relay (`agent/error`). Both + * capture paths apply the fixed chunk projection, build logical records, and + * run each through the * `telemetry/record` waterfall (deployment-mounted redaction rules; - * pass-through when none), then hands the result to the backend immediately - * or holds it for explicit release. Every synchronous handler is - * self-contained so a failing backend can never starve other subscribers - * (cordis `emit` is stop-on-throw) or touch the agent loop. Composed by a - * backend in its constructor. + * pass-through when none), then hands the result to the backend. Live capture + * follows the session firehose; on-demand capture replays the canonical log + * only when requested. Every synchronous handler is self-contained so a + * failing backend can never starve other subscribers (cordis `emit` is + * stop-on-throw) or touch the agent loop. Composed by a backend in its + * constructor. * * @module @deepseek-ai/dsh-session-telemetry/coordinator */ @@ -17,11 +19,11 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' -/** Whether capture hands records over immediately or holds them for an explicit release. */ -export type TelemetryDelivery = 'immediate' | 'held' +/** Whether capture follows live events or reads the canonical log only when requested. */ +export type TelemetryCapture = 'live' | 'on-demand' -/** One redacted record waiting at the capture boundary. */ -interface PendingRecord { +/** One projected record ready for backend handoff. */ +interface ProjectedRecord { readonly record: TelemetryRecord /** Ledger cursor advanced only after the backend accepts this record. */ readonly seq?: number @@ -43,16 +45,17 @@ const handoffCursor = new WeakMap<Session, number>() /** * Install the telemetry capture side onto a context for one backend. * - * Registers the persistence-coordinator listener set plus the `agent/error` - * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and - * sweeps already-live sessions (a hot reload does not replay + * Live capture registers the persistence-coordinator listener set plus the + * `agent/error` relay, all through `ctx.effect()`/`ctx.on()` on the composing + * fiber, and sweeps already-live sessions (a hot reload does not replay * `session/created`). A `session/disposed` captures the session's `shutdown` * operational record at its own termination edge and retires it from the - * adopted set. Immediate delivery hands that marker over; held delivery keeps - * it local without another explicit release. Disposal captures the same - * marker for sessions still alive, then awaits the backend's `shutdown()`; a - * failure there warns instead of throwing — best-effort reporting must not - * fail application teardown. + * adopted set. On-demand capture registers none of those continuous listeners; + * {@link captureSession} reads the canonical log explicitly and never creates + * operational records. Disposal captures shutdown markers for live-adopted + * sessions, then awaits the backend's `shutdown()`; a failure there warns + * instead of throwing — best-effort reporting must not fail application + * teardown. */ export class TelemetryCoordinator { /** @@ -63,56 +66,55 @@ export class TelemetryCoordinator { private readonly adopted = new Set<Session>() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap<Session, Set<string>>() - /** Redacted records retained until {@link release}; weak keys do not extend session lifetime. */ - private readonly held = new WeakMap<Session, PendingRecord[]>() - /** * @param ctx - the composing backend's context; listeners bind to its fiber. * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding. - * @param delivery - immediate handoff, or held delivery released explicitly per session. + * @param capture - follow live events, or wait for explicit canonical-log capture. */ constructor( private readonly ctx: Context, private readonly backend: TelemetryBackend, - private readonly delivery: TelemetryDelivery = 'immediate', + capture: TelemetryCapture = 'live', ) { - ctx.on('session/created', (session) => { - this.adopt(session) - }) - // Capture the shutdown marker at the session's own termination edge. - // Immediate delivery preserves crash classification; held delivery does - // not let a later lifecycle edge extend a user-released prefix. Then - // retire the only strong reference owned by this coordinator. - ctx.on('session/disposed', (session) => { - this.contain(() => { - if (!this.adopted.delete(session)) return - this.submit(session, { record: this.redact(shutdownRecord(session)) }) + if (capture === 'live') { + ctx.on('session/created', (session) => { + this.adopt(session) }) - }) - ctx.on('session/event', (session, event) => { - this.contain(() => { - this.capture(session, event) + // Capture the shutdown marker at the session's own termination edge, + // then retire the only strong reference owned by this coordinator. + ctx.on('session/disposed', (session) => { + this.contain(() => { + if (!this.adopted.delete(session)) return + this.deliver(session, { record: this.redact(shutdownRecord(session)) }) + }) }) - }) - // Parallel listeners are awaited by the loop at turn end; returning void - // (not the SDK's flush promise) is the turn-latency contract. - ctx.on('session/flush', (session) => { - this.contain(() => { - this.hintFlush(session) + ctx.on('session/event', (session, event) => { + this.contain(() => { + this.captureEvent(session, event) + }) }) - }) - ctx.on('agent/error', (agent, turn, step, error) => { - this.contain(() => { - this.relayAgentError(agent, turn, step, error) + // Parallel listeners are awaited by the loop at turn end; returning void + // (not the SDK's flush promise) is the turn-latency contract. + ctx.on('session/flush', (session) => { + this.contain(() => { + this.hintFlush(session) + }) }) - }) + ctx.on('agent/error', (agent, turn, step, error) => { + this.contain(() => { + this.relayAgentError(agent, turn, step, error) + }) + }) + for (const session of ctx.sessions.list()) { + this.adopt(session) + } + } ctx.effect(() => async () => { // Sessions still adopted here are alive through whole-application - // teardown, so capture the marker before the backend quiesces. Held - // delivery intentionally leaves it local without another release. + // teardown, so capture the marker before the backend quiesces. for (const session of this.adopted) { this.contain(() => { - this.submit(session, { record: this.redact(shutdownRecord(session)) }) + this.deliver(session, { record: this.redact(shutdownRecord(session)) }) }) } try { @@ -121,24 +123,27 @@ export class TelemetryCoordinator { this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`) } }, 'telemetry capture') - for (const session of ctx.sessions.list()) { - this.adopt(session) - } } /** - * Hand the records currently held for one session to the backend in capture order. - * Records captured after this call form a new held prefix. Backend failures remain - * contained per record and do not starve later records in the same release. - * @param session - session whose pending capture prefix may leave the process. + * Project and hand over the canonical session-log suffix after the handoff + * cursor, optionally stopping at an inclusive sequence boundary. Redaction + * runs during this call, so an on-demand caller retains no copied records + * before requesting capture and uses the policy mounted at that time. + * Backend and policy failures remain contained per event and do not starve + * later events in the same replay. + * @param session - session whose current canonical-log prefix may be handed over. + * @param throughSeq - optional last sequence included in this capture. */ - release(session: Session): void { - const pending = this.held.get(session) - if (pending === undefined) return - this.held.delete(session) - for (const record of pending) { + captureSession(session: Session, throughSeq?: number): void { + const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 + // Containment is PER EVENT: one rejected record is withheld fail-closed + // while the rest of the historical replay proceeds. + for (const event of session.events) { + if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { - this.deliver(session, record) + if (event.seq <= cursor) this.track(session, event) + else this.captureEvent(session, event) }) } } @@ -161,17 +166,7 @@ export class TelemetryCoordinator { private adopt(session: Session): void { if (this.adopted.has(session)) return this.adopted.add(session) - const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 - // Containment is PER EVENT, matching the firehose: one rejected record - // is withheld fail-closed while the rest of the historical replay - // proceeds — wrapping the whole loop would let a single failure silently - // skip the remainder of the log on an already-adopted session. - for (const event of session.events) { - this.contain(() => { - if (event.seq <= cursor) this.track(session, event) - else this.capture(session, event) - }) - } + this.captureSession(session) } /** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */ @@ -181,8 +176,8 @@ export class TelemetryCoordinator { } } - /** Project and redact one event, then submit it under the delivery policy. */ - private capture(session: Session, event: SessionEvent): void { + /** Project, redact, and hand one event to the backend. */ + private captureEvent(session: Session, event: SessionEvent): void { if (event.type === 'assistant/chunk') { const key = `${event.data.turn}:${event.data.step}` const seen = this.seen(session) @@ -193,14 +188,14 @@ export class TelemetryCoordinator { if (seen.has(key)) return seen.add(key) } - this.submit(session, { + this.deliver(session, { record: this.redact({ channel: 'ledger', time: event.time, severity: severityOf(event), attributes: identityOf(session, event), - // The live event object is mutable and the backend serializes later; - // append-time validation guarantees this clone cannot throw. + // The canonical event object is mutable and the backend serializes + // later; append-time validation guarantees this clone cannot throw. body: structuredClone(event.data), }), seq: event.seq, @@ -212,26 +207,15 @@ export class TelemetryCoordinator { * passes the record through unchanged — the seam ships no rules; exported * data is as clean as the listeners a deployment mounts. Callers run inside * {@link contain}, so a throwing rule withholds the record instead of - * reaching the loop (fail-closed). Held delivery stores only this result, so - * a later policy reload cannot expose the pre-redaction capture. + * reaching the loop (fail-closed). On-demand capture invokes this waterfall + * while reading the canonical session log, not when the event was appended. */ private redact(record: TelemetryRecord): TelemetryRecord { return this.ctx.waterfall('telemetry/record', record, () => record) } - /** Hold one redacted record or deliver it immediately under the configured policy. */ - private submit(session: Session, pending: PendingRecord): void { - if (this.delivery === 'held') { - let records = this.held.get(session) - if (records === undefined) this.held.set(session, records = []) - records.push(pending) - return - } - this.deliver(session, pending) - } - /** Hand one redacted record to the backend, then advance its ledger cursor. */ - private deliver(session: Session, pending: PendingRecord): void { + private deliver(session: Session, pending: ProjectedRecord): void { this.backend.emit(pending.record) if (pending.seq !== undefined) handoffCursor.set(session, pending.seq) } @@ -244,7 +228,7 @@ export class TelemetryCoordinator { /** Relay one `agent/error` bus emission as an `agent-error` operational record. */ private relayAgentError(agent: Agent, turn: number, step: number, error: unknown): void { const detail = errorDetail(error) - this.submit(agent.session, { + this.deliver(agent.session, { record: this.redact({ channel: 'ops', time: Date.now(), diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 914ef96a95..0198df3140 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -4,9 +4,9 @@ * The seam owns the CAPTURE side of session-event reporting — which records * exist (the chunk projection), what they carry (the logical record), when * they are captured (adoption, the per-append firehose, lifecycle - * forwarding), immediate versus explicitly released handoff, and the HMR + * forwarding), live versus on-demand canonical-log capture, and the HMR * cursor. Everything downstream of - * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the + * {@link Telemetry.emit} — batching, retry, queueing, and loss policy — is the * reporting SDK's territory and is deliberately not modelled here. The * design and its trade-offs are pinned in * .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md. @@ -33,8 +33,9 @@ declare module 'cordis' { * `next()` replaces everything beneath. Dispatched synchronously on the * capture hot path inside the coordinator's containment: a throwing * listener withholds that one record (fail-closed) and never reaches the - * agent loop. Redaction applies to the exported copy only; the canonical - * session log is never rewritten. + * agent loop. Live capture dispatches at append time; on-demand capture + * dispatches while reading the canonical log. Redaction applies to the + * exported copy only; the canonical session log is never rewritten. * @param record - the candidate record, already the coordinator's own deep * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall @@ -95,8 +96,8 @@ export interface TelemetryBackend { /** * Hand one record to the backend's pipeline. MUST be a non-blocking * enqueue — the coordinator calls this synchronously from the - * `session/event` hot path, either at capture or while releasing a held - * prefix, so anything slower than a queue push would tax the agent loop. + * `session/event` hot path or an explicit canonical-log capture, so anything + * slower than a queue push would tax the agent loop or feedback handling. * Errors thrown here are contained by the coordinator and logged; they * never reach the loop. * @param record - the logical record to report; owned by the backend after the call. @@ -123,9 +124,8 @@ export interface TelemetryBackend { * coordinator emits its dispose-time `shutdown` markers immediately before * calling this). Awaited by the coordinator's dispose; a rejection is * logged as a warning and never fails application teardown. - * The coordinator captures dispose-time shutdown markers immediately - * before this call; immediate delivery enqueues them, while held delivery - * leaves an unreleased suffix local. + * The coordinator captures dispose-time shutdown markers immediately before + * this call for live capture; on-demand capture creates no ops records. * @returns resolves when the backend's pipeline has quiesced. */ shutdown(): Promise<void> @@ -158,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend { abstract shutdown(): Promise<void> } -export { TelemetryCoordinator, type TelemetryDelivery } from './coordinator.ts' +export { TelemetryCoordinator, type TelemetryCapture } from './coordinator.ts' diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index d913e6a742..f368e80979 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -13,7 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { TelemetryCoordinator, type TelemetryBackend, - type TelemetryDelivery, + type TelemetryCapture, type TelemetryRecord, } from '../src/index.ts' @@ -61,7 +61,7 @@ class FakeBackend implements TelemetryBackend { async function setup( backend: FakeBackend = new FakeBackend(), - delivery: TelemetryDelivery = 'immediate', + capture: TelemetryCapture = 'live', ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -70,7 +70,7 @@ async function setup( name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => { - coordinator = new TelemetryCoordinator(inner, backend, delivery) + coordinator = new TelemetryCoordinator(inner, backend, capture) }, }) return { ctx, backend, coordinator, fiber } @@ -178,23 +178,24 @@ describe('TelemetryCoordinator capture', () => { }) }) -describe('TelemetryCoordinator held delivery', () => { - it('releases one pending prefix at a time without handing later records over early', async () => { - const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held') - const session = liveSession(ctx, 'held-prefix') +describe('TelemetryCoordinator on-demand capture', () => { + it('captures one canonical-log prefix at a time without following later events', async () => { + const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand') + const session = liveSession(ctx, 'on-demand-prefix') appendTurn(session) + const firstBoundary = session.events[1]!.seq + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(backend.records).toEqual([]) - coordinator.release(session) + coordinator.captureSession(session, firstBoundary) expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([ 'turn/start', 'user/message', ]) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(backend.ledger()).toHaveLength(2) - coordinator.release(session) - coordinator.release(session) + coordinator.captureSession(session) + coordinator.captureSession(session) expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([ 'turn/start', 'user/message', @@ -202,38 +203,42 @@ describe('TelemetryCoordinator held delivery', () => { ]) }) - it('stores the capture-time redacted copy rather than re-running policy at release', async () => { - const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held') + it('runs the currently mounted redaction policy during canonical-log capture', async () => { + const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand') + const session = liveSession(ctx, 'on-demand-redacted') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) const disposeRule = ctx.on('telemetry/record', (_record, next) => ({ ...next(), body: { scrubbed: true }, })) - const session = liveSession(ctx, 'held-redacted') - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + + coordinator.captureSession(session) + expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true }) disposeRule() - coordinator.release(session) - expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + coordinator.captureSession(session) + expect(backend.ledger()[1]!.body).toEqual({ turn: 1, reason: { kind: 'completed' } }) }) - it('contains each backend failure independently while releasing a batch', async () => { + it('contains each backend failure independently while replaying a prefix', async () => { const backend = new FakeBackend() backend.rejectSeq = 1 - const { ctx, coordinator } = await setup(backend, 'held') + const { ctx, coordinator } = await setup(backend, 'on-demand') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const session = liveSession(ctx, 'held-failure') + const session = liveSession(ctx, 'on-demand-failure') appendTurn(session) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - coordinator.release(session) + coordinator.captureSession(session) expect(backend.ledger().map(record => record.attributes['event.seq'])).toEqual([0, 2]) expect(warn).toHaveBeenCalled() }) - it('rebuilds an unreleased prefix after coordinator reload', async () => { + it('captures a pending prefix after coordinator reload without retained records', async () => { const first = new FakeBackend() - const { ctx, fiber } = await setup(first, 'held') - const session = liveSession(ctx, 'held-reload') + const { ctx, fiber } = await setup(first, 'on-demand') + const session = liveSession(ctx, 'on-demand-reload') session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) await fiber.dispose() expect(first.records).toEqual([]) @@ -241,15 +246,34 @@ describe('TelemetryCoordinator held delivery', () => { const second = new FakeBackend() let coordinator!: TelemetryCoordinator await ctx.plugin({ - name: 'fake-telemetry-after-held-reload', + name: 'fake-telemetry-after-on-demand-reload', inject: ['sessions'], apply: (inner: Context) => { - coordinator = new TelemetryCoordinator(inner, second, 'held') + coordinator = new TelemetryCoordinator(inner, second, 'on-demand') }, }) - coordinator.release(session) + coordinator.captureSession(session) expect(second.ledger().map(record => record.attributes['event.seq'])).toEqual([0]) }) + + it('registers no continuous capture, flush, or ops listeners', async () => { + const { ctx, backend, coordinator, fiber } = await setup(new FakeBackend(), 'on-demand') + const redact = vi.fn((_record: TelemetryRecord, next: () => TelemetryRecord) => next()) + ctx.on('telemetry/record', redact) + const session = liveSession(ctx, 'on-demand-ledger-only') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await ctx.parallel('session/flush', session) + const agent = { id: 'agent-1', session } as Agent + ctx.emit('agent/error', agent, 1, 1, new Error('local only')) + expect(backend.flush).not.toHaveBeenCalled() + expect(backend.records).toEqual([]) + expect(redact).not.toHaveBeenCalled() + + coordinator.captureSession(session) + expect(redact).toHaveBeenCalledTimes(1) + await fiber.dispose() + expect(backend.records.map(record => record.channel)).toEqual(['ledger']) + }) }) describe('TelemetryCoordinator adoption', () => { From c6f8055388feda7b5298e5d03fb9b9f46fa778cf Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 15:03:52 +0800 Subject: [PATCH 127/190] docs(telemetry): update capture vocabulary --- packages/telemetry/README.i18n.yaml | 4 ++-- packages/telemetry/README.md | 2 +- packages/telemetry/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/telemetry/README.i18n.yaml b/packages/telemetry/README.i18n.yaml index cd3be8d155..fba4bd4339 100644 --- a/packages/telemetry/README.i18n.yaml +++ b/packages/telemetry/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/telemetry/README.md -README.md: 0adf140a19bd6ab19c4d4139d4ebdae941c0d1b0 -README.zh.md: 57988732e36d105ebcc48adcdab9344a6cccb525 +README.md: ddac6c6cdc5a7326190283fbe232b6985deb4927 +README.zh.md: 6863ca56d4f63fccb60eb3c92acd1e70b25e27dd diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index 0adf140a19..ddac6c6cdc 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -6,5 +6,5 @@ Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. T | Package | Role | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, immediate or held handoff, cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, live or on-demand capture, cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | | [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: `FULL`, `FEEDBACK_ONLY`, or `DISABLED` policy around the OTel JS SDK log pipeline. | diff --git a/packages/telemetry/README.zh.md b/packages/telemetry/README.zh.md index 57988732e3..6863ca56d4 100644 --- a/packages/telemetry/README.zh.md +++ b/packages/telemetry/README.zh.md @@ -6,5 +6,5 @@ | 包(package) | 职责 | |---|---| -| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、即时或暂存交接、游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、实时或按需捕获、游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 | | [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:围绕 OTel JS SDK 日志流水线实施 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 策略。 | From b10368a8d53d8cafb1ee991c4b1b9519b8271403 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 15:34:36 +0800 Subject: [PATCH 128/190] refactor(telemetry): strongly type sharing mode --- ...feedback-gated-session-telemetry.i18n.yaml | 4 +-- ...-08-05-feedback-gated-session-telemetry.md | 2 +- ...-05-feedback-gated-session-telemetry.zh.md | 2 +- docs/config-catalog.md | 8 ++++-- .../tests/gen-config-catalog.spec.ts | 23 ++++++++++++++++ .../session-telemetry-otel/README.i18n.yaml | 4 +-- .../session-telemetry-otel/README.md | 2 ++ .../session-telemetry-otel/README.zh.md | 2 ++ .../session-telemetry-otel/src/index.ts | 26 ++++++++++++------- .../session-telemetry-otel/tests/otel.spec.ts | 18 ++++++++----- scripts/gen-config-catalog.ts | 9 ++++--- 11 files changed, 73 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index 7909316acd..331b2e97c6 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: 25cc17f75629f72d7351eb0537d72b700c84411f -2026-08-05-feedback-gated-session-telemetry.zh.md: b0e84e60e27fa20f66113c11db62026583a27a19 +2026-08-05-feedback-gated-session-telemetry.md: 00a8f23fa6bf69f10277ad0d9f2513a0df73de16 +2026-08-05-feedback-gated-session-telemetry.zh.md: 888ce48abe7a5ce2212c152730f8203f5747ef41 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index 25cc17f756..00a8f23fa6 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -10,7 +10,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent ## Decision -`@deepseek-ai/dsh-session-telemetry-otel` exposes three uppercase `mode` values: +`@deepseek-ai/dsh-session-telemetry-otel` exposes the string-valued `TelemetryMode` enum to TypeScript callers and accepts the same three uppercase `mode` values in serialized configuration: - `FULL` is the default and preserves immediate delivery to the configured OTel pipeline. - `FEEDBACK_ONLY` reads the canonical session log when `feedback/record` is appended and hands over the unreleased prefix through that exact event. Records appended after that boundary remain local until another feedback event. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index b0e84e60e2..888ce48abe 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-session-telemetry-otel` 公开三个大写的 `mode` 值: +`@deepseek-ai/dsh-session-telemetry-otel` 向 TypeScript 调用方公开以字符串为值的 `TelemetryMode` 枚举,并在序列化配置中接受相同的三个大写 `mode` 值: - `FULL` 是默认值,保留向已配置 OTel 流水线的即时投递。 - `FEEDBACK_ONLY` 在追加 `feedback/record` 时读取权威会话日志,并交接截至该事件的未释放前缀。该边界后追加的记录会留在本地,直到另一个反馈事件。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a4ddedab35..a2f82d2bec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1184,12 +1184,16 @@ export interface Config { } /** Session-sharing policy selected by {@link Config.mode}. */ -export type TelemetryMode = typeof TELEMETRY_MODES[number] +export enum TelemetryMode { + FULL = 'FULL', + FEEDBACK_ONLY = 'FEEDBACK_ONLY', + DISABLED = 'DISABLED', +} ``` Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:55`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:63`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts index 65a6f6b9ff..ae4966fd12 100644 --- a/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts +++ b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts @@ -160,6 +160,29 @@ export function apply(ctx: Context, config: Config): void {} expect(entries[0]?.refs).toEqual([{ alias: 'Remote', imported: 'Remote', specifier: '@fix/dep' }]) }) + it('pastes an enum referenced by the config type', () => { + const entries = collectConfigCatalog(make({ + 'src/index.ts': `import type { Context } from 'cordis' +/** Fixture mode. */ +export enum Mode { + A = 'a', + B = 'b', +} +/** Fixture config. */ +export interface Config { + /** The mode. */ + mode?: Mode +} +/** Load. */ +export function apply(ctx: Context, config: Config): void {} +`, + })) + expect(entries[0]?.pastes?.map(p => p.text)).toEqual([ + '/** Fixture config. */\nexport interface Config {\n /** The mode. */\n mode?: Mode\n}', + "/** Fixture mode. */\nexport enum Mode {\n A = 'a',\n B = 'b',\n}", + ]) + }) + it('hard-errors on a referenced type name that resolves nowhere', () => { expect(() => collectConfigCatalog(make({ 'src/index.ts': `import type { Context } from 'cordis' diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 84e2447fd8..5a6bd9bc9d 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md -README.md: 7fc5572614a5bdba312ba97b52606032ef8f5394 -README.zh.md: 3160b67c8225fb87d5e7be2e43453ef40496fba9 +README.md: 01d803236329afbe65e2d92960928441aaff301c +README.zh.md: 8adf4a3c11b95dc302f8afd0dc79e99433e50f22 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 7fc5572614..01d8032363 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -24,6 +24,8 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th | `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. | | `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. | +Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above. + `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 3160b67c82..8adf4a3c11 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -24,6 +24,8 @@ | `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | | `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 | +程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL`、`TelemetryMode.FEEDBACK_ONLY` 或 `TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。 + `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`,SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。 ## 哪些数据会离开本机 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 908f0f90fb..95991cb932 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -39,11 +39,19 @@ import { resourceFromAttributes } from '@opentelemetry/resources' // version (same pattern as dsh-llm's attribution identity). const { version } = createRequire(import.meta.url)('../package.json') as { version: string } -/** Supported session-sharing policies for the OTel backend. */ -export const TELEMETRY_MODES = ['FULL', 'FEEDBACK_ONLY', 'DISABLED'] as const - /** Session-sharing policy selected by {@link Config.mode}. */ -export type TelemetryMode = typeof TELEMETRY_MODES[number] +export enum TelemetryMode { + FULL = 'FULL', + FEEDBACK_ONLY = 'FEEDBACK_ONLY', + DISABLED = 'DISABLED', +} + +/** Supported session-sharing policies for runtime configuration validation. */ +export const TELEMETRY_MODES = [ + TelemetryMode.FULL, + TelemetryMode.FEEDBACK_ONLY, + TelemetryMode.DISABLED, +] as const const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' @@ -81,7 +89,7 @@ export interface Config { * axiom (and silently drop every field not re-declared). */ export const Config: z<Config> = z.object({ - mode: z.union(TELEMETRY_MODES).default('FULL'), + mode: z.union(TELEMETRY_MODES).default(TelemetryMode.FULL), exporter: z.any(), processor: z.any(), }) @@ -109,8 +117,8 @@ export class TelemetryOtel extends Telemetry { constructor(ctx: Context, config: Config) { super(ctx) - const mode = config.mode ?? 'FULL' - if (mode === 'DISABLED') { + const mode = config.mode ?? TelemetryMode.FULL + if (mode === TelemetryMode.DISABLED) { this.provider = undefined this.ledger = undefined this.ops = undefined @@ -162,9 +170,9 @@ export class TelemetryOtel extends Telemetry { }) this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) - const capture: TelemetryCapture = mode === 'FULL' ? 'live' : 'on-demand' + const capture: TelemetryCapture = mode === TelemetryMode.FULL ? 'live' : 'on-demand' const coordinator = new TelemetryCoordinator(ctx, this, capture) - if (mode === 'FEEDBACK_ONLY') { + if (mode === TelemetryMode.FEEDBACK_ONLY) { // Session.append commits before publishing `session/event`, so the // canonical log already includes this feedback record when replay begins. ctx.on('session/event', (session, event) => { diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 9b7e4119b7..26118e4672 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -5,7 +5,7 @@ * for the default-exported Service class. */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { createServer, type Server } from 'node:http' import { once } from 'node:events' import { gunzipSync } from 'node:zlib' @@ -13,7 +13,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import TelemetryOtel, { Config } from '../src/index.ts' +import TelemetryOtel, { Config, TelemetryMode } from '../src/index.ts' interface Capture { headers: import('node:http').IncomingHttpHeaders @@ -211,7 +211,7 @@ describe('TelemetryOtel wire', () => { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TelemetryOtel, { - mode: 'FEEDBACK_ONLY', + mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url }, }) const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} }) @@ -236,7 +236,7 @@ describe('TelemetryOtel wire', () => { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TelemetryOtel, { - mode: 'FEEDBACK_ONLY', + mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url }, }) const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} }) @@ -249,7 +249,7 @@ describe('TelemetryOtel wire', () => { const ctx = new Context() await ctx.plugin(SessionStore) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const fiber = await ctx.plugin(TelemetryOtel, { mode: 'DISABLED' }) + const fiber = await ctx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED }) const session = ctx.sessions.create(SessionId('disabled'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) recordFeedback(session, 'local report') @@ -284,12 +284,18 @@ describe('TelemetryOtel wire', () => { }) describe('TelemetryOtel config fails loud', () => { + it('exposes modes through the nominal enum', () => { + expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>() + expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>() + expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>() + }) + it.each([ [{}, /exporter\.url is required/], [{ exporter: { url: '' } }, /exporter\.url is required/], [{ exporter: { url: 'not a url' } }, /not a valid URL/], [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], - [{ mode: 'FEEDBACK_ONLY' }, /exporter\.url is required/], + [{ mode: TelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/], [{ mode: 'INVALID' }, /INVALID/], // The SDK accepts a non-positive batch size but its shutdown drain then // splices empty batches forever — dispose would hang, so reject at load. diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index b4c20d596a..920688df6d 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -126,12 +126,13 @@ function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCt } /** A type declaration a paste can contain. */ -type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration +type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration -/** Find an interface/type-alias declaration by name in a file, or null. */ +/** Find a pasteable type declaration by name in a file, or null. */ function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null { for (const stmt of ctx.sf.statements) { - if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name.text === name) return stmt + if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) + && stmt.name.text === name) return stmt } return null } @@ -207,7 +208,7 @@ function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): vo else ts.forEachChild(type, (n) => { walkNested(n, path) }) } if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text) - else walkNested(decl.type, decl.name.text) + else if (ts.isTypeAliasDeclaration(decl)) walkNested(decl.type, decl.name.text) } /** Cross-file resolution context for the schema-path check. */ From 9db4372af80230b9c4be533d068bb07005eddd6b Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 16:15:58 +0800 Subject: [PATCH 129/190] fix: align feedback package publication files --- packages/feedback/command-feedback/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 25bc8446c3..535c438a63 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { From ccb0842cfcc23ca11a89c136761e355eb0c94741 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 16:27:27 +0800 Subject: [PATCH 130/190] fix(telemetry): fail closed outside full mode --- ...feedback-gated-session-telemetry.i18n.yaml | 4 +- ...-08-05-feedback-gated-session-telemetry.md | 6 +- ...-05-feedback-gated-session-telemetry.zh.md | 6 +- docs/config-catalog.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 2 + .../session-telemetry-otel/README.zh.md | 2 + .../session-telemetry-otel/src/index.ts | 91 +++++++++++++------ .../session-telemetry-otel/tests/otel.spec.ts | 64 ++++++++++++- 9 files changed, 139 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index 331b2e97c6..4255886987 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: 00a8f23fa6bf69f10277ad0d9f2513a0df73de16 -2026-08-05-feedback-gated-session-telemetry.zh.md: 888ce48abe7a5ce2212c152730f8203f5747ef41 +2026-08-05-feedback-gated-session-telemetry.md: 7d923a7e4cf61e8d1119187564b87e4cbb2065b7 +2026-08-05-feedback-gated-session-telemetry.zh.md: 2862162c0c36e5194846c7e1c7bbc24230ce90aa diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index 00a8f23fa6..7d923a7e4c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -18,7 +18,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. -The OTel feedback listener passes the feedback event's sequence to `captureSession()`. `Session.append` commits the event before publishing `session/event`, so replay includes that feedback but cannot extend past its boundary. `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`; `DISABLED` does not validate or use exporter configuration. +Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when the exact `feedback/record` object is already stored at `session.events[event.seq]`. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. ## Alternatives considered @@ -26,8 +26,10 @@ The OTel feedback listener passes the feedback event's sequence to `captureSessi **Retain capture-time redacted records until feedback.** Rejected because it duplicates an unbounded session prefix even though the canonical log already owns the events. It preserves capture-time redaction policy and operational records, but those properties do not justify the memory cost for a mode defined as uploading the session log after feedback. +**Temporarily allow public `emit()` calls during feedback replay.** Rejected because a redaction listener or another reentrant caller could enqueue an unrelated record while the flag was open. A private backend capability makes authorization structural and keeps the public service closed throughout replay. + **Use an unmounted plugin as the disabled state.** That remains the silent opt-out, but it cannot warn when feedback is recorded. The explicit disabled mode lets a deployment keep one configuration shape and communicate that the local feedback did not leave the process. ## Consequences -`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. +`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index 888ce48abe..2862162c0c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -18,7 +18,7 @@ Status: implemented 通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)说明了按需路径为何使用权威日志而非记录副本。 -OTel 反馈监听器把反馈事件的序列号传给 `captureSession()`。`Session.append` 在发布 `session/event` 前已提交该事件,因此回放会包含该反馈,但不会超过其边界。`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填;`DISABLED` 不校验也不使用导出器配置。 +模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是该事件与那个 `feedback/record` 对象身份完全相同,且该对象已存储于 `session.events[event.seq]`。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 ## 考虑过的替代方案 @@ -26,8 +26,10 @@ OTel 反馈监听器把反馈事件的序列号传给 `captureSession()`。`Sess **反馈前保留捕获时已脱敏记录。** 已否决,因为权威日志已拥有这些事件,该方案仍会复制无上限的会话前缀。它能保留捕获时的脱敏策略与运维记录,但对于一个定义为「反馈后上传会话日志」的模式,这些性质不足以证明该内存成本合理。 +**在反馈回放期间临时允许公开 `emit()` 调用。** 已否决,因为在标志开启期间,脱敏监听器或另一个可重入调用方可能将无关记录入队。私有后端能力使授权成为结构性保证,并确保公共服务在整个回放过程中保持关闭。 + **以不挂载插件表示禁用状态。** 这仍然是静默退出方式,但无法在记录反馈时输出警告。显式禁用模式让部署方可以保持同一种配置形态,并说明本地反馈未离开进程。 ## 后果 -`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;反馈前发生崩溃时,该前缀不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 +`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,且反馈前发生崩溃时,该前缀也不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a2f82d2bec..c386ee4e6b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1193,7 +1193,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:63`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:83`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 5a6bd9bc9d..ec7fddf6b4 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/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/telemetry/session-telemetry-otel/README.md -README.md: 01d803236329afbe65e2d92960928441aaff301c -README.zh.md: 8adf4a3c11b95dc302f8afd0dc79e99433e50f22 +README.md: af177dc86bc30a7b17e34e3c8c3592326b9026f2 +README.zh.md: 9a3ad628bb7d480a1c4cd8346669ad8ebbd6258f diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 01d8032363..af177dc86b 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -26,6 +26,8 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above. +Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present. + `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 8adf4a3c11..9a3ad628bb 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -26,6 +26,8 @@ 程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL`、`TelemetryMode.FEEDBACK_ONLY` 或 `TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。 +上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。 + `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`,SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。 ## 哪些数据会离开本机 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 95991cb932..f380d97549 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -20,7 +20,7 @@ import type {} from '@deepseek-ai/dsh-command-feedback' import { Telemetry, TelemetryCoordinator, - type TelemetryCapture, + type TelemetryBackend, type TelemetryRecord, type TelemetrySeverity, } from '@deepseek-ai/dsh-session-telemetry' @@ -54,6 +54,26 @@ export const TELEMETRY_MODES = [ ] as const const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' +const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log' +const DROP_RECORD: TelemetryBackend['emit'] = () => {} + +/** Resolve the default and reject unknown runtime values before transport setup. */ +function resolveMode(mode: TelemetryMode | undefined): TelemetryMode { + const resolved = mode ?? TelemetryMode.FULL + switch (resolved) { + case TelemetryMode.FULL: + case TelemetryMode.FEEDBACK_ONLY: + case TelemetryMode.DISABLED: + return resolved + default: + return assertNever(resolved) + } +} + +/** Fail closed when direct construction bypasses the runtime config schema. */ +function assertNever(value: never): never { + throw new Error(`session-telemetry-otel: unsupported mode ${JSON.stringify(value)}`) +} /** * Plugin configuration: one sharing policy plus two verbatim SDK option @@ -111,17 +131,15 @@ export class TelemetryOtel extends Telemetry { static inject = ['sessions'] static Config = Config + private readonly directEmit: TelemetryBackend['emit'] private readonly provider: LoggerProvider | undefined - private readonly ledger: Logger | undefined - private readonly ops: Logger | undefined constructor(ctx: Context, config: Config) { + const mode = resolveMode(config.mode) super(ctx) - const mode = config.mode ?? TelemetryMode.FULL if (mode === TelemetryMode.DISABLED) { + this.directEmit = DROP_RECORD this.provider = undefined - this.ledger = undefined - this.ops = undefined ctx.on('session/event', (_session, event) => { if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING) }) @@ -168,37 +186,50 @@ export class TelemetryOtel extends Telemetry { }), ], }) - this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) - this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) - const capture: TelemetryCapture = mode === TelemetryMode.FULL ? 'live' : 'on-demand' - const coordinator = new TelemetryCoordinator(ctx, this, capture) - if (mode === TelemetryMode.FEEDBACK_ONLY) { - // Session.append commits before publishing `session/event`, so the - // canonical log already includes this feedback record when replay begins. - ctx.on('session/event', (session, event) => { - if (event.type === 'feedback/record') coordinator.captureSession(session, event.seq) + const ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) + const ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) + const enqueue: TelemetryBackend['emit'] = (record) => { + const logger: Logger = record.channel === 'ops' ? ops : ledger + logger.emit({ + timestamp: record.time, + observedTimestamp: record.time, + ...SEVERITY[record.severity], + // JSON-serializable by the seam's contract (validated at Session.append), + // which is exactly the AnyValue subset. + body: record.body as AnyValue, + attributes: record.attributes, }) } + const backend: TelemetryBackend = { + emit: enqueue, + shutdown: () => this.shutdown(), + } + if (mode === TelemetryMode.FULL) { + this.directEmit = enqueue + new TelemetryCoordinator(ctx, backend, 'live') + return + } + this.directEmit = DROP_RECORD + const coordinator = new TelemetryCoordinator(ctx, backend, 'on-demand') + ctx.on('session/event', (session, event) => { + if (event.type !== 'feedback/record') return + // Consent is the committed record, not an independently emitted bus value. + if (session.events[event.seq] !== event) { + ctx.logger.warn(NON_CANONICAL_FEEDBACK_WARNING) + return + } + coordinator.captureSession(session, event.seq) + }) } /** - * Map one seam record onto the SDK logger for its channel — a synchronous - * enqueue into the batch processor's queue. Direct calls are no-ops in - * `DISABLED`, where no coordinator or SDK pipeline exists. - * @param record - the logical record handed over by the coordinator. + * Hand a direct service record to the SDK only in `FULL`. Direct calls are + * no-ops in `FEEDBACK_ONLY` and `DISABLED`; feedback replay uses a private + * backend capability created only for the canonical feedback listener. + * @param record - the logical record offered directly to the service. */ emit(record: TelemetryRecord): void { - const logger = record.channel === 'ops' ? this.ops : this.ledger - if (logger === undefined) return - logger.emit({ - timestamp: record.time, - observedTimestamp: record.time, - ...SEVERITY[record.severity], - // JSON-serializable by the seam's contract (validated at Session.append), - // which is exactly the AnyValue subset. - body: record.body as AnyValue, - attributes: record.attributes, - }) + this.directEmit(record) } // The seam's optional flush() hint is deliberately NOT implemented. The diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 26118e4672..f7b3a007c9 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -105,6 +105,13 @@ describe('TelemetryOtel wire', () => { const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + ctx.telemetry.emit({ + channel: 'ledger', + time: Date.now(), + severity: 'info', + attributes: { 'session.id': 'wire', 'event.type': 'manual', 'event.seq': 99 }, + body: { direct: true }, + }) await fiber.dispose() expect(captures.length).toBeGreaterThan(0) @@ -128,6 +135,7 @@ describe('TelemetryOtel wire', () => { const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end')) expect(end?.record.severityNumber).toBe(17) expect(end?.record.severityText).toBe('ERROR') + expect(eventTypes(captures)).toContain('manual') expect(ops).toHaveLength(1) expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) @@ -214,6 +222,16 @@ describe('TelemetryOtel wire', () => { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url }, }) + ctx.on('telemetry/record', (_record, next) => { + ctx.telemetry.emit({ + channel: 'ledger', + time: Date.now(), + severity: 'info', + attributes: { 'session.id': 'feedback-only', 'event.type': 'direct-bypass', 'event.seq': 99 }, + body: { mustStayLocal: true }, + }) + return next() + }) const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) recordFeedback(session, 'first report') @@ -231,25 +249,48 @@ describe('TelemetryOtel wire', () => { expect(allRecords(captures).some(({ scope }) => scope.endsWith('/ops'))).toBe(false) }) - it('sends no request when feedback-only mode ends without feedback', async () => { + it('ignores direct emits and non-canonical feedback in feedback-only mode', async () => { const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const fiber = await ctx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url }, }) const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.telemetry.emit({ + channel: 'ledger', + time: Date.now(), + severity: 'info', + attributes: { 'session.id': 'no-feedback', 'event.type': 'direct', 'event.seq': 99 }, + body: { mustStayLocal: true }, + }) + ctx.emit('session/event', session, { + type: 'feedback/record', + seq: session.events.length, + time: Date.now(), + data: { text: 'not committed' }, + }) await fiber.dispose() + + expect(warn).toHaveBeenCalledWith( + 'session telemetry ignored a feedback event absent from the canonical session log', + ) expect(captures).toEqual([]) }) - it('boots disabled without exporter config and warns when feedback stays local', async () => { + it('constructs no disabled transport even when exporter options are present', async () => { + const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const fiber = await ctx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED }) + const fiber = await ctx.plugin(TelemetryOtel, { + mode: TelemetryMode.DISABLED, + exporter: { url }, + processor: { maxExportBatchSize: 0 }, + }) const session = ctx.sessions.create(SessionId('disabled'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) recordFeedback(session, 'local report') @@ -268,6 +309,7 @@ describe('TelemetryOtel wire', () => { await fiber.dispose() recordFeedback(session, 'after disposal') expect(warn).toHaveBeenCalledTimes(1) + expect(captures).toEqual([]) }) it('defaults direct construction to full delivery', async () => { @@ -306,6 +348,22 @@ describe('TelemetryOtel config fails loud', () => { await ctx.plugin(SessionStore) await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message) }) + + it('rejects an unknown direct mode before reading transport config', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let exporterRead = false + const config = { + mode: 'INVALID', + get exporter() { + exporterRead = true + throw new Error('transport config was read') + }, + } as unknown as Config + + expect(() => new TelemetryOtel(ctx, config)).toThrow(/unsupported mode "INVALID"/) + expect(exporterRead).toBe(false) + }) }) describe('dsh-session-telemetry-otel real-load-path guard', () => { From 4f595311f793bf2759243aefd31901bfc9c72aac Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 16:32:46 +0800 Subject: [PATCH 131/190] test: include feedback in Web command catalog snapshot --- .../tests/snapshots/lifecycle-chrome/command-menu.expected.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md index 1da9b9a45e..7b18ab188b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md @@ -1,6 +1,7 @@ - listbox "Trigger suggestions": - text: Commands - option "compact Compact older conversation history" [selected] + - option "feedback record feedback about this session" - option "goal set or view the goal for a long-running task" - option "permission Switch the permission preset (sandbox mode + approval policy)" - option "plan Enter or leave plan mode" From 53e210348d90c1653d8181c594d4c179eeb35de6 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Wed, 5 Aug 2026 16:40:29 +0800 Subject: [PATCH 132/190] fix(web): grant turn-tail IconActions only after the turn ends `assistantActionsSeqs` picked the last content-text assistant of each turn from the finalized transcript alone. That quantity is stable only once the turn closes: while a turn is still producing steps, the narration written before a tool call is the last content assistant so far, so copy, branch, and the clock appeared under an intermediate sentence for as long as the tool ran and then moved down to the next step's text. Pass `ConversationSnapshot.turnEnds` into the derivation and grant the row only inside a turn that has a durable `turn/end`. This is the same completion fact the branch control and the `Ran for` label already read, so the three parts of one row now agree; mid-turn narration owns nothing, and the seat appears once under the settled answer. `hasContentText` moves to chat-flow.ts so the ownership gate and AssistantMarkdown's mount gate cannot drift apart. apps/web/tests/turn-tail-actions.e2e.ts pins both states through the assembled application: a hang sidecar on the second model call parks a turn whose first step narrated before calling bash, and the two goldens hold the parked flow and the flow after stopping. --- ...actions-require-a-completed-turn.i18n.yaml | 6 + ...n-tail-actions-require-a-completed-turn.md | 31 ++++ ...ail-actions-require-a-completed-turn.zh.md | 31 ++++ ...b-message-icon-actions-and-clock.i18n.yaml | 4 +- ...7-29-web-message-icon-actions-and-clock.md | 2 + ...9-web-message-icon-actions-and-clock.zh.md | 2 + .../turn-tail-actions/running.expected.md | 38 +++++ .../snapshots/turn-tail-actions/session.jsonl | 36 +++++ .../turn-tail-actions/settled.expected.md | 43 +++++ apps/web/tests/turn-tail-actions.e2e.ts | 147 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../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/AssistantMarkdown.tsx | 17 +- .../src/client/chat/ChatView.tsx | 7 +- .../src/client/chat/chat-flow.ts | 22 ++- .../ui-conversation/tests/chat-view.spec.tsx | 37 ++++- tsconfig.host.json | 1 + 19 files changed, 404 insertions(+), 29 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md create mode 100644 apps/web/tests/snapshots/turn-tail-actions/running.expected.md create mode 100644 apps/web/tests/snapshots/turn-tail-actions/session.jsonl create mode 100644 apps/web/tests/snapshots/turn-tail-actions/settled.expected.md create mode 100644 apps/web/tests/turn-tail-actions.e2e.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml new file mode 100644 index 0000000000..b94c395c70 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md +2026-08-05-turn-tail-actions-require-a-completed-turn.md: b6d59c7d73daaea0233e51e5626ee8cbbec639dd +2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: c89859d779c6c07c4576056bbe1c4e32250eb294 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md new file mode 100644 index 0000000000..b6d59c7d73 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md @@ -0,0 +1,31 @@ +# Agent Note: Turn-tail IconActions require a completed turn + +Status: implemented + +English | [中文](2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md) + +## Problem + +Assistant IconActions were derived from the finalized transcript alone: the last content-text assistant of each turn owned the row. That quantity is stable only after the turn closes. While a turn is still producing steps, the narration a model writes before a tool call *is* the last content assistant so far, so it took the row for as long as the tool ran and then lost it to the next step's text. Readers saw copy, branch, and a clock appear under an intermediate sentence, shift the flow by one 28px row, and disappear. The row was also incoherent in that state: its branch control was already disabled through `turnEnds`, and its `Ran for` label was already withheld through `turnTimings`, so only copy worked. + +The [message chrome decision](../feature/2026-07-29-web-message-icon-actions-and-clock.md) always claimed mid-turn narration stays chrome-free; the derivation never carried a completion signal to make that true. + +## Decision + +`assistantActionsSeqs` takes `ConversationSnapshot.turnEnds` and grants the row only within a turn that has a `turn/end` in the window. Ownership inside a completed turn is unchanged: its last content-text assistant. A turn still producing steps grants nothing, so its narration never mounts the row, and the seat appears once, under the settled answer, when the turn closes. + +This is the same completion fact the branch control and the run-time label already use, so the three parts of one row now agree. Turn completion is read from the durable `turn/end` event rather than inferred from `running`, the streaming partial, or in-flight tool calls, matching the [completed-turn-tail decision](2026-08-02-message-fork-actions-require-completed-turn-tail.md). Every reason kind closes a turn, so an aborted turn's frozen tail keeps its footer, and a crash-orphaned turn receives its `turn/end` from log repair on load. + +`hasContentText` moves to `chat-flow.ts` and `AssistantMarkdown` imports it, so the ownership gate and the mount gate cannot drift apart. + +## Alternatives considered + +**Withhold by naming the open turn from `running` plus the streaming partial or the first in-flight tool call.** This shipped briefly in the original change and was then dropped. It infers completion instead of reading it, needs a special case so a turn accepted before its first step does not strip the previous answer's seat, and is the inference the completed-turn-tail decision rejected for the branch control. `turnEnds` answers the same question per turn with no inference and no special case. + +**Leave the row mounted mid-turn and disable its controls.** Rejected: mid-turn narration is not a degraded answer, it is not the answer. Copy would still write an intermediate sentence, and the row would still move to the real tail at turn end. + +**Keep the row under every finalized content node permanently.** Rejected again here for the reason the original decision gave: repeating copy, branch, and a clock under every step clutters the flow. It also does not solve the reported problem, since the branch control is only meaningful on the tail. + +## Consequences + +During a running turn the conversation carries no message footer past the user bubble; the seat appears once when `turn/end` lands, which adds one 28px row under the settled answer at that moment. A turn whose `turn/end` is outside the loaded window grants nothing, which cannot arise from paging because a turn's end follows its own nodes. `apps/web/tests/turn-tail-actions.e2e.ts` pins both states through the assembled application: a `hang` sidecar on the second model call parks a turn whose first step narrated before calling bash, and the two goldens hold the parked flow and the flow after stopping. Package tests cover the derivation directly and the running-turn render. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md new file mode 100644 index 0000000000..c89859d779 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 轮次尾部 IconActions 要求轮次已完成 + +Status: implemented + +[English](2026-08-05-turn-tail-actions-require-a-completed-turn.md) | 中文 + +## 问题 + +assistant IconActions 此前只从已定稿的 transcript(文本记录)推导:每个轮次中最后一条含内容文本的 assistant 拥有该行。这个量只有在轮次关闭后才稳定。轮次仍在产出步骤时,模型在工具调用前写下的叙述就是当时该轮次的最后一条内容 assistant,于是它在工具执行期间取得该行,等下一步的文本落定又把它交出去。读者会看到复制、分支和时钟出现在一句中间叙述下方,把流程推开一行 28px,然后消失。该行在这个状态下本身也是残缺的:分支控件已经通过 `turnEnds` 判定为禁用,`Ran for` 标签已经通过 `turnTimings` 判定为不显示,只有复制可用。 + +[消息 chrome 决策](../feature/2026-07-29-web-message-icon-actions-and-clock.md)一直声称轮次中间的叙述不带 chrome,但推导过程从未拿到能让这句话成立的完成信号。 + +## 决策 + +`assistantActionsSeqs` 接收 `ConversationSnapshot.turnEnds`,只在事件窗口中存在该轮次 `turn/end` 时才授予该行。已完成轮次内部的归属不变,仍是其最后一条含内容文本的 assistant。仍在产出步骤的轮次不授予任何座位,因此其叙述不会挂载该行;轮次关闭时,座位在已定稿答案下方一次性出现。 + +这与分支控件和运行时长标签使用的完成事实相同,因此同一行的三个部分现在口径一致。轮次是否完成读自持久的 `turn/end` 事件,而不是从 `running`、流式 partial 或在途工具调用推断,与[已完成轮次尾部决策](2026-08-02-message-fork-actions-require-completed-turn-tail.md)一致。任何 reason 类别都会关闭轮次,因此已中止轮次冻结的尾部保留其操作栏,而崩溃遗留的开放轮次会在加载时由日志修复补上 `turn/end`。 + +`hasContentText` 移入 `chat-flow.ts`,由 `AssistantMarkdown` 导入,使归属门控与挂载门控无法各自漂移。 + +## 考虑过的替代方案 + +**用 `running` 加流式 partial 或第一个在途工具调用指认开放轮次,据此扣留。** 这一做法曾在最初的变更中短暂存在,随后被删除。它推断完成状态而不是读取完成状态,还需要一个特例,避免轮次已被接受但尚未产出第一步时把上一条回答的座位取走;这正是已完成轮次尾部决策为分支控件否决过的推断。`turnEnds` 按轮次回答同一个问题,不需要推断,也不需要特例。 + +**轮次进行中保留该行,只把控件置为不可用。** 不予采纳:轮次中间的叙述不是一个降级的答案,它根本不是答案。复制仍然会写入一句中间文本,该行在轮次结束时仍然要移动到真正的尾部。 + +**让每个已定稿的内容节点长期保留该行。** 在此重新否决,理由与最初的决策相同:在每一步下重复复制、分支和时钟会打乱流程。它也解决不了本次报告的问题,因为分支控件只有落在尾部才有意义。 + +## 后果 + +轮次运行期间,会话中除用户气泡外不再有任何消息操作栏;座位在 `turn/end` 到达时一次性出现,此刻已定稿答案下方会多出一行 28px。`turn/end` 落在加载窗口之外的轮次不授予座位,而翻页不会造成这种情况,因为一个轮次的结束事件排在它自己的节点之后。`apps/web/tests/turn-tail-actions.e2e.ts` 通过组装后的应用钉住两种状态:`hang` sidecar 作用在第二次模型调用上,把一个首步先叙述再调用 bash 的轮次挂住,两份 golden 分别记录挂起中的流程和停止之后的流程。包级测试直接覆盖该推导以及运行中轮次的渲染结果。 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index 717f40df0b..3c7f8f4992 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: f43f7f9c9687e4494993d7e225d11cf6446a9954 -2026-07-29-web-message-icon-actions-and-clock.zh.md: a6261c65c1e9d77cea2de5624b2c9fde1278c612 +2026-07-29-web-message-icon-actions-and-clock.md: 3b97089cdffe006bbb401c4cf61c1379da7f8828 +2026-07-29-web-message-icon-actions-and-clock.zh.md: abb6e200ccea4a227e5db3ac48f0410cb3349526 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index f43f7f9c96..3b97089cdf 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -12,6 +12,8 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo **User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** +The assistant seat is narrowed by the [completed-turn decision](../bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md): only a turn with a `turn/end` grants it, so a turn still producing steps hands the row to nothing. + Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds `time` for mid-turn content; `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, and the streaming tail omit the row. Copy writes joined text blocks. Both message rows pass their event's `seq` to the same fork callback; [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the real mutation contract. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index a6261c65c1..abb6e200cc 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -12,6 +12,8 @@ Web 聊天的用户气泡已有复制、分支、编辑 IconActions,但没有 **用户气泡在既有 IconActions 行的开头添加感知日期的本地时钟;每个轮次中最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制、分支、时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** +assistant 一侧的座位由[已完成轮次决策](../bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md)收紧:只有存在 `turn/end` 的轮次才授予该行,仍在产出步骤的轮次不把该行交给任何节点。 + 两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架钩子。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导轮次尾部的 seq,并不为轮次中间的内容传入 `time`;`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、轮次中间的叙述与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 `seq` 交给同一个 fork 回调;真实 mutation 契约由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装后的界面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 diff --git a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md new file mode 100644 index 0000000000..7780798b41 --- /dev/null +++ b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md @@ -0,0 +1,38 @@ +- banner: + - navigation "Session hierarchy": + - button "Begin your reply with the" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} +- button "Copy": + - img +- tooltip "Copy" +- 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": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop.": + - img + - img + - text: Think The user wants me to begin with "Reading the workspace now." and call bash with "echo alpha" in the same message. Then after the tool result, reply with the single word DONE and stop. +- paragraph: Reading the workspace now. +- button "Bash Print alpha to stdout": + - img + - img + - text: Bash Print alpha to stdout +- paragraph: partial +- status: Deep diving... +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Stop generating" +- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 7.8K tok · Output 109 tok diff --git a/apps/web/tests/snapshots/turn-tail-actions/session.jsonl b/apps/web/tests/snapshots/turn-tail-actions/session.jsonl new file mode 100644 index 0000000000..b951ae3559 --- /dev/null +++ b/apps/web/tests/snapshots/turn-tail-actions/session.jsonl @@ -0,0 +1,36 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785918212891,"cwd":"{{cwd}}/workspace"} +{"type":"permission/preset","seq":0,"time":1785918212892,"data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","seq":1,"time":1785918212893,"data":{"mode":"workspace-write"}} +{"type":"approval/policy","seq":2,"time":1785918212893,"data":{"policy":"ask"}} +{"type":"turn/start","seq":3,"time":1785918212945,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":4,"time":1785918212945,"data":{"content":[{"type":"text","text":"Begin your reply with the plain sentence \"Reading the workspace now.\" as text, and in that same message call the bash tool with the command \"echo alpha\". After the tool result, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"4dcaa766-7ea2-4c6a-84cb-0d6ab53b5fb4"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1785918212946,"data":{"title":"Begin your reply with the","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":6,"time":1785918212956,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"60b8851a-888c-4d7e-9513-7d845f8d769b"},"surfaceOp":"append"} +{"type":"step/start","seq":7,"time":1785918212956,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":8,"time":1785918212957,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785918212958,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"assistant/chunk","seq":10,"time":1785918214389,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1785918214390,"data":{"turn":1,"step":1,"index":0,"dt":[101,1,0,0,0,56,1,0,0,0,0,0,0,72,1,0,0,0,0,29,0,0,0,0,35,1,0,17,39,0,0,0,0,0,31,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," begin"," with"," \"","Reading"," the"," workspace"," now",".\""," and"," call"," bash"," with"," \"","echo"," alpha","\""," in"," the"," same"," message","."," Then"," after"," the"," tool"," result",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} +{"type":"assistant/chunk","seq":53,"time":1785918214774,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":54,"time0":1785918214774,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,48],"texts":["Reading"," the"," workspace"," now","."]}} +{"type":"assistant/chunk","seq":59,"time":1785918214841,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":60,"time0":1785918214842,"data":{"turn":1,"step":1,"index":2,"dt":[28,0,0,0,0,25,0,0,0,52,1,0,0,0,25,0,0,1,15,0,25],"id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," alpha","\"",", ","\"","description","\"",": ","\"","Print"," alpha"," to"," stdout","\"","}"]}} +{"type":"assistant/chunk","seq":82,"time":1785918215056,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop."}}}} +{"type":"assistant/chunk","seq":83,"time":1785918215057,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Reading the workspace now."}}}} +{"type":"assistant/chunk","seq":84,"time":1785918215057,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}}}} +{"type":"assistant/chunk","seq":85,"time":1785918215057,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":7788,"outputTokens":109,"cacheReadTokens":0,"reasoningTokens":42}}}} +{"type":"assistant/chunk","seq":86,"time":1785918215057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":87,"time":1785918215061,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop."},{"type":"text","text":"Reading the workspace now."},{"type":"tool-call","id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73708391-4b5f-429e-b71c-ef2114244a95"},"usage":{"inputTokens":7788,"outputTokens":109,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"} +{"type":"tool/call","seq":88,"time":1785918215062,"data":{"turn":1,"step":1,"callId":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}} +{"type":"tool/result","seq":89,"time":1785918215096,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1yZGg4XTqe0N5r1rnDLx5082"},"content":[{"type":"tool-result","toolCallId":"call_00_1yZGg4XTqe0N5r1rnDLx5082","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"8b7ad694-b19e-4728-a804-eef9f53820b9"}},"sourceEventSeqs":[88],"surfaceOp":"append"} +{"type":"step/end","seq":90,"time":1785918215097,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":91,"time":1785918215106,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":92,"time":1785918216259,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":93,"time":1785918216259,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":94,"time":1785918216288,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":95,"time":1785918216289,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":96,"time":1785918216289,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":103,"outputTokens":3,"cacheReadTokens":7808,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":97,"time":1785918216289,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":98,"time":1785918216289,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fbb5a6d0-9945-4b52-ad15-978173d450a7"},"usage":{"inputTokens":103,"outputTokens":3,"cacheReadTokens":7808,"reasoningTokens":0}},"sourceEventSeqs":[92,93,94,95,96,97],"surfaceOp":"append"} +{"type":"step/end","seq":99,"time":1785918216289,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":100,"time":1785918216289,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md new file mode 100644 index 0000000000..082aecaf9b --- /dev/null +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -0,0 +1,43 @@ +- banner: + - navigation "Session hierarchy": + - button "Begin your reply with the" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} +- button "Copy": + - img +- 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": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop.": + - img + - img + - text: Think The user wants me to begin with "Reading the workspace now." and call bash with "echo alpha" in the same message. Then after the tool result, reply with the single word DONE and stop. +- paragraph: Reading the workspace now. +- button "Bash Print alpha to stdout": + - img + - img + - text: Bash Print alpha to stdout +- paragraph: partial +- text: Stopped +- button "Copy": + - img +- tooltip "Copy" +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 7.8K tok · Output 109 tok diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts new file mode 100644 index 0000000000..19d14a7a0c --- /dev/null +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -0,0 +1,147 @@ +// Web e2e scenario: assistant IconActions belong to the settled answer, so +// they arrive with `turn/end` and not before. The recorded turn narrates in +// plain text before its tool call, which is the shape that used to hand the +// footer to mid-turn narration for the seconds a tool runs and then move it +// down. A `hang` sidecar on the SECOND model call parks the turn after the +// narration and the tool result are durable, so the running state is stable by +// construction rather than by timing; stopping from that park writes the +// `turn/end` that hands the footer to the turn's transcript tail. +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/turn-tail-actions', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +// Two goldens for the same message: parked mid-turn, then settled. +const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() + +// The recording must carry text in the SAME assistant message as the tool +// call; a Think-only step would leave nothing for the footer to attach to and +// the scenario would pass against either implementation. +const NARRATION = 'Reading the workspace now.' +const PROMPT = `Begin your reply with the plain sentence "${NARRATION}" as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop.` + +describe('web e2e: assistant IconActions wait for the turn to end', () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + let tripwire: ReturnType<typeof watchConsole> + let sessionEvents: SessionEvent[] + let sidecarDir: string | undefined + + afterEach(async () => { + // close() carries the fixture-consumption tripwire, so its failure is the + // scenario's failure; run every teardown step, then rethrow what failed. + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + browser = undefined + const closing = scaffold + scaffold = undefined + await closing?.close().catch((error: unknown) => failures.push(error)) + if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + sidecarDir = undefined + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'turn-tail-actions teardown failed') + }) + + /** Boot scaffold + page, materializing the sidecar before the replay row installs. */ + async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise<void> { + sessionEvents = [] + let overridePath: string | undefined + if (buildOverride !== undefined) { + sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-')) + overridePath = join(sidecarDir, 'replay.override.json') + await writeFile(overridePath, JSON.stringify(buildOverride(sidecarDir))) + } + scaffold = await launchWebScaffold( + MODE === 'record' + ? {} + : { replayFixture: FIXTURE, ...(overridePath === undefined ? {} : { replayOverride: overridePath }) }, + ) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + } + + /** Send the recorded prompt with the settled barrier pre-armed (returned wrapped so the caller can act mid-turn). */ + async function sendPrompt(timeoutMs?: number): Promise<{ settled: ReturnType<WebScaffold['whenTurnSettled']> }> { + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold!.whenTurnSettled(timeoutMs) + await input.fill(PROMPT) + await input.press('Enter') + return { settled } + } + + it.skipIf(MODE !== 'record')('records the narrate-then-call turn live through the composer', async () => { + await launch() + onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-record')) + const { settled } = await sendPrompt(180_000) + const sessionId = await settled + await recordFixture(scaffold!, sessionId, FIXTURE) + }, 200_000) + + it.skipIf(MODE === 'record')('withholds the footer while the turn runs and grants it at turn/end', async () => { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + let marker = '' + // Patch the SECOND call: the first one delivers the narration and the tool + // call as recorded, so the park happens with a durable mid-turn message. + await launch((sidecarHome) => { + marker = join(sidecarHome, '.hang-ready') + return { patches: [{ at: 1, entry: { kind: 'hang', readyFile: marker } }] } + }) + onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions')) + const { settled } = await sendPrompt() + // The marker IS the synchronization: the second call is provably parked, + // so the first step's message and tool result are already durable. + await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true) + await expect.poll(() => page.getByText(NARRATION, { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll( + () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), + { timeout: 10_000 }, + ).toBe(true) + // Only the user bubble owns a footer: the narration is not the answer yet. + const copyButtons = page.getByRole('button', { name: 'Copy' }) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(1) + expect(await page.getByRole('button', { name: 'Branch into a new conversation' }).count()).toBe(1) + await copyButtons.first().focus() + const running = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE) + + // Closing the turn from the park is the state change under test: an + // aborted turn is durably closed, so its transcript tail (the frozen + // partial) takes the seat while the mid-turn narration keeps none. + await page.getByRole('button', { name: 'Stop generating' }).click() + await settled + expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted']) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2) + await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) + await copyButtons.last().focus() + const settledAria = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settledAria, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 120_000) + + it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'session.jsonl', 'settled.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index dd5fe879e7..665733f237 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -62,6 +62,7 @@ "tests/startup-auto-selection.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/bash-abort-row.e2e.ts", + "tests/turn-tail-actions.e2e.ts", "tests/chat-scroll-fixture.ts", "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0df2b4b4df..50a28ac676 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: 7bd0d551fc41967326dd9860f5c31a99ea3c254a -README.zh.md: d339f6423d9a9f77c02d86ad0b8e57bd0baba52b +README.md: c01be00a82a23feeaae18bd55668163803de9ef7 +README.zh.md: c5102576e4e030f0662135baa6c9a3d30e1ad846 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7bd0d551fc..c01be00a82 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -63,7 +63,7 @@ None; this package neither assembles nor sends a provider request. - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **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. - **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. -- **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)). +- **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 that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps 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)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel has no durable grant control** — it supports allow-once and reject only. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d339f6423d..c5102576e4 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -63,7 +63,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 -- **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))。 +- **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))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 5b3b9fa821..8342bf8478 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,10 +4,10 @@ // view groups them into tool rows through its keyed toolview slot (figma // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. -// Finalized content (text) nodes append IconActions once streaming ends -// (`time` is omitted for mid-turn narration); their branch action is enabled -// only when the node is also the completed turn's transcript tail. Think / -// tool-head-only nodes stay chrome-free. +// Finalized content (text) nodes append IconActions once their turn ends +// (`time` is omitted for mid-turn narration and while the turn still runs); +// their branch action is enabled only when the node is also the completed +// turn's transcript tail. Think / tool-head-only nodes stay chrome-free. import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -15,6 +15,7 @@ import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { hasContentText } from './chat-flow.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -25,7 +26,8 @@ export interface AssistantMarkdownProps { /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined /** Unix epoch ms for the IconActions clock; omitted while streaming or when - * the parent withholds chrome (mid-turn content assistants). */ + * the parent withholds chrome (mid-turn content assistants and every node + * of a turn that has not ended). */ time?: number | undefined /** Turn wall time in ms for the IconActions run-time label; omitted when the * turn's triggering input is outside the loaded window. */ @@ -65,11 +67,6 @@ function copyText(blocks: readonly AssistantBlock[]): string { return parts.join('') } -/** True when the node has model-visible text content worth chrome under. */ -function hasContentText(blocks: readonly AssistantBlock[]): boolean { - return blocks.some(block => block.kind === 'text' && block.text.trim() !== '') -} - /** Reasoning block as the Think variant summary row (figma 39:28304). */ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) { return ( diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index c852161240..e902a5c75d 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -358,9 +358,10 @@ export function ChatView({ [inbox], ) const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) - // Only the last content assistant of each turn owns IconActions; mid-turn - // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. - const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) + // Only the last content assistant of each completed turn owns IconActions; + // mid-turn text and every node of a running turn omit `time`, so + // AssistantMarkdown stays chrome-free until the answer settles. + const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds]) const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 57d2ac1bb0..31523ae365 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -17,8 +17,14 @@ export type ChatFlowItem = | { kind: 'node'; key: string; node: ConversationNode } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } -/** True when the node has model-visible text content worth IconActions chrome. */ -function hasContentText(blocks: readonly AssistantBlock[]): boolean { +/** + * True when the node has model-visible text content worth IconActions chrome. + * Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting + * cannot diverge. + * @param blocks - assistant blocks of one finalized node. + * @returns Whether any text block carries non-blank content. + */ +export function hasContentText(blocks: readonly AssistantBlock[]): boolean { return blocks.some(block => block.kind === 'text' && block.text.trim() !== '') } @@ -34,14 +40,20 @@ function rendersNothing(node: ConversationNode): boolean { /** * Seq set of assistants that own IconActions: the last content-text assistant - * in each turn. Mid-turn narration (text before tools) stays chrome-free. + * of each *completed* turn. A turn without a `turn/end` in the window is still + * producing steps, so its latest narration is not the settled answer and owns + * nothing; mid-turn narration of a completed turn stays chrome-free too. * @param nodes - snapshot nodes (surface order). + * @param turnEnds - completed turn boundaries retained from the event window. * @returns Seq values ChatView may pass as `time` into AssistantMarkdown. */ -export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> { +export function assistantActionsSeqs( + nodes: readonly ConversationNode[], + turnEnds: ReadonlyMap<number, number>, +): ReadonlySet<number> { const lastByTurn = new Map<number, number>() for (const node of nodes) { - if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue + if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue lastByTurn.set(node.turn, node.seq) } return new Set(lastByTurn.values()) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b7ca8dd149..8702d9bcde 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -225,12 +225,12 @@ describe('chat-flow derivation', () => { expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') }) - it('assistantActionsSeqs keeps only the last content assistant per turn', () => { + it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => { const thinkOnly: AssistantMessageNode = { kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'planning' }], } - const seqs = assistantActionsSeqs([ + const nodes: ConversationNode[] = [ user(1, 'hi'), assistant(2, 'looking', 1), thinkOnly, @@ -238,8 +238,11 @@ describe('chat-flow derivation', () => { assistant(5, 'done', 1), user(6, 'again'), assistant(7, 'second turn', 2), - ]) - expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) + ] + expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7]) + // Turn 2 is still producing steps: its latest narration owns nothing, and + // the settled turn 1 keeps its seat. + expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5]) }) it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { @@ -401,7 +404,9 @@ 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) + // Only the durable steering bubble: the turn is still running, so its + // assistant narration owns no footer yet. + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1) const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' }) expect(unavailable.getAttribute('aria-disabled')).toBe('true') @@ -525,6 +530,28 @@ describe('ChatView', () => { expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null]) }) + it('withholds assistant IconActions while the turn is still running', () => { + const h = makeHarness({ + running: true, + runningCalls: [runningCall('a')], + nodes: [ + user(1, 'first'), + assistant(2, 'previous answer', 1), + user(3, 'second'), + assistant(4, 'mid-turn text', 2), + ], + turnEnds: new Map([[1, 2]]), + }) + const view = render(<h.ChatView {...h.props} />) + // 2 user + the settled turn-1 tail; turn 2's narration stays chrome-free + // while its tool runs, so the footer never appears and then moves. + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3) + expect(view.getByText('mid-turn text')).toBeTruthy() + // turn/end lands: the same node becomes the settled answer and takes the seat. + act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 2], [2, 5]]) }) }) + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) + }) + it('the actions-owning assistant footer shows the turn run time', () => { const h = makeHarness({ nodes: [ diff --git a/tsconfig.host.json b/tsconfig.host.json index 4fcf71b680..5b1189059a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", + "apps/web/tests/turn-tail-actions.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", From 6902b51feaf8f9df1e3da27fdd542b1dc24d1b59 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Wed, 5 Aug 2026 16:55:20 +0800 Subject: [PATCH 133/190] fix(web): address review on the turn-tail actions gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the Agent Note's consequence: a running turn withholds the footer below its own trigger bubble, while every earlier completed turn keeps its seat — which the package test already asserts. Give the running-phase barrier an explicit budget: it is armed before the park and awaited after the stop click, so the 30s replay default left no headroom for the marker poll, the UI polls, and two aria captures. Number the running-turn test's boundary seqs like the log does, with each turn/end strictly after its own nodes. --- ...tail-actions-require-a-completed-turn.i18n.yaml | 4 ++-- ...5-turn-tail-actions-require-a-completed-turn.md | 2 +- ...urn-tail-actions-require-a-completed-turn.zh.md | 2 +- apps/web/tests/turn-tail-actions.e2e.ts | 6 +++++- .../ui-conversation/tests/chat-view.spec.tsx | 14 ++++++++------ 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml index b94c395c70..72d3ae50b8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md -2026-08-05-turn-tail-actions-require-a-completed-turn.md: b6d59c7d73daaea0233e51e5626ee8cbbec639dd -2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: c89859d779c6c07c4576056bbe1c4e32250eb294 +2026-08-05-turn-tail-actions-require-a-completed-turn.md: 689d50bb86c830d6e428239f112568f00d74c9b8 +2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: 2cc426bbb82acb8f57d491b0f068e89771699357 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md index b6d59c7d73..689d50bb86 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md @@ -28,4 +28,4 @@ This is the same completion fact the branch control and the run-time label alrea ## Consequences -During a running turn the conversation carries no message footer past the user bubble; the seat appears once when `turn/end` lands, which adds one 28px row under the settled answer at that moment. A turn whose `turn/end` is outside the loaded window grants nothing, which cannot arise from paging because a turn's end follows its own nodes. `apps/web/tests/turn-tail-actions.e2e.ts` pins both states through the assembled application: a `hang` sidecar on the second model call parks a turn whose first step narrated before calling bash, and the two goldens hold the parked flow and the flow after stopping. Package tests cover the derivation directly and the running-turn render. +A running turn carries no message footer below the user bubble that triggered it, while every earlier completed turn keeps its own; the seat appears once when `turn/end` lands, which adds one 28px row under the settled answer at that moment. A turn whose `turn/end` is outside the loaded window grants nothing, which cannot arise from paging because a turn's end follows its own nodes. `apps/web/tests/turn-tail-actions.e2e.ts` pins both states through the assembled application: a `hang` sidecar on the second model call parks a turn whose first step narrated before calling bash, and the two goldens hold the parked flow and the flow after stopping. Package tests cover the derivation directly and the running-turn render. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md index c89859d779..2cc426bbb8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md @@ -28,4 +28,4 @@ assistant IconActions 此前只从已定稿的 transcript(文本记录)推 ## 后果 -轮次运行期间,会话中除用户气泡外不再有任何消息操作栏;座位在 `turn/end` 到达时一次性出现,此刻已定稿答案下方会多出一行 28px。`turn/end` 落在加载窗口之外的轮次不授予座位,而翻页不会造成这种情况,因为一个轮次的结束事件排在它自己的节点之后。`apps/web/tests/turn-tail-actions.e2e.ts` 通过组装后的应用钉住两种状态:`hang` sidecar 作用在第二次模型调用上,把一个首步先叙述再调用 bash 的轮次挂住,两份 golden 分别记录挂起中的流程和停止之后的流程。包级测试直接覆盖该推导以及运行中轮次的渲染结果。 +运行中的轮次在触发它的用户气泡之下不再有任何消息操作栏,而此前每个已完成轮次仍保留各自的座位;座位在 `turn/end` 到达时一次性出现,此刻已定稿答案下方会多出一行 28px。`turn/end` 落在加载窗口之外的轮次不授予座位,而翻页不会造成这种情况,因为一个轮次的结束事件排在它自己的节点之后。`apps/web/tests/turn-tail-actions.e2e.ts` 通过组装后的应用钉住两种状态:`hang` sidecar 作用在第二次模型调用上,把一个首步先叙述再调用 bash 的轮次挂住,两份 golden 分别记录挂起中的流程和停止之后的流程。包级测试直接覆盖该推导以及运行中轮次的渲染结果。 diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 19d14a7a0c..11e22d29d4 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -109,7 +109,11 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { return { patches: [{ at: 1, entry: { kind: 'hang', readyFile: marker } }] } }) onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions')) - const { settled } = await sendPrompt() + // The barrier is armed before the park and awaited only after the stop + // click, so its budget must cover the whole parked phase: marker poll, + // three UI polls, and two captures with their stability windows. The + // replay default (30s) leaves no headroom on a slow runner. + const { settled } = await sendPrompt(120_000) // The marker IS the synchronization: the second call is provably parked, // so the first step's message and tool result are already durable. await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 8702d9bcde..20daca0d7f 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -537,18 +537,20 @@ describe('ChatView', () => { nodes: [ user(1, 'first'), assistant(2, 'previous answer', 1), - user(3, 'second'), - assistant(4, 'mid-turn text', 2), + user(4, 'second'), + assistant(5, 'mid-turn text', 2), ], - turnEnds: new Map([[1, 2]]), + // Boundary seqs follow the log: a turn/end is strictly after its own nodes. + turnEnds: new Map([[1, 3]]), }) const view = render(<h.ChatView {...h.props} />) - // 2 user + the settled turn-1 tail; turn 2's narration stays chrome-free - // while its tool runs, so the footer never appears and then moves. + // 2 user + the settled turn-1 tail, which keeps its seat while a later + // turn runs; turn 2's narration stays chrome-free while its tool runs, so + // the footer never appears and then moves. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3) expect(view.getByText('mid-turn text')).toBeTruthy() // turn/end lands: the same node becomes the settled answer and takes the seat. - act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 2], [2, 5]]) }) }) + act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) }) expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) }) From f2050bfd1e6c3b655c041fb2a07fa92f25f62749 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 16:44:04 +0800 Subject: [PATCH 134/190] fix(web): surface provider credential status --- ...06-provider-credential-lifecycle.i18n.yaml | 4 +- ...026-08-06-provider-credential-lifecycle.md | 4 +- ...-08-06-provider-credential-lifecycle.zh.md | 4 +- apps/web/tests/models-settings.e2e.ts | 15 +++- .../models-settings/configured.expected.md | 2 + .../models.expected.md | 1 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 31 ++++++++ .../ui-models/src/client/ModelsSection.tsx | 70 +++++++++++++++---- .../client/ui-models/src/client/locales.ts | 6 ++ .../ui-models/tests/components.spec.tsx | 33 +++++++++ 13 files changed, 153 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml index 11ba2e0744..9f16a183b9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md -2026-08-06-provider-credential-lifecycle.md: 6965d573af6989dffd7b6066fd8b3e50872a6a25 -2026-08-06-provider-credential-lifecycle.zh.md: de6f76d0725e954e27ec99062832fe40c36fcfe9 +2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09 +2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md index 6965d573af..ce45207e7a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md @@ -12,7 +12,7 @@ The Models editor spans independent settings and credential RPC domains. It prev Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery. -Deletion removes a credential only when the joined row identifies the exact `<ROUTE>_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. +Deletion removes a credential only when the joined row identifies the exact `<ROUTE>_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. ## Alternatives considered @@ -24,4 +24,4 @@ Deletion removes a credential only when the joined row identifies the exact `<RO ## Consequences -The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, normalized literals, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both `settings.yaml` profile and `.env` credential. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md). +The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Confirmed status is visible without turning route liveness, native authentication, or a failed credential lookup into a false error, and a successful replacement remains observable even when the row stays green. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, normalized literals, status visibility, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both `settings.yaml` profile and `.env` credential. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md). diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md index de6f76d072..c476417517 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md @@ -12,7 +12,7 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它 提供方保存仍在现有 wire 领域上按先 settings、后凭据的两阶段顺序执行,但卡片会把成功的 settings 响应视为提交检查点。它会在尝试 `credentials.set` 之前,用返回的脱敏 descriptor 替换比较基准子树与预期 revision;如果第二阶段失败,草稿密钥与卡片会继续显示,重试不会产生 settings op,只会再次写入凭据。首次 settings 提交之前发生的真实并发变更仍会以 `settings-conflict` 失败。UI 与 DeepSeek 直连 resolver 边界均会去除所输密钥的首尾空白,且只有标准化密钥非空时,pi-ai 才会记录派生引用;留空密钥会具化一个空的、不带引用的 profile,以便使用提供方原生凭据发现。 -只有当联接所得的行识别出该页面派生的精确 `<ROUTE>_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。 +只有当联接所得的行识别出该页面派生的精确 `<ROUTE>_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 ## 曾考虑的替代方案 @@ -24,4 +24,4 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它 ## 后果 -Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 +Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 9078e53ff6..c688e12e6a 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -73,7 +73,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(options).toContain('anthropic') expect(options).toContain('minimax-cn') await pick.selectOption('minimax-cn') - await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 }) + await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE) }, 60_000) @@ -84,6 +84,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByRole('button', { name: '保存', exact: true }).click() const row = dialog.getByText('minimax-cn', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('img', { name: 'API 密钥已配置' }).count()).toBe(0) + expect(await dialog.getByRole('img', { name: 'API 密钥缺失' }).count()).toBe(0) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn: {}') expect(document).not.toContain('MINIMAX_CN_API_KEY') @@ -108,12 +111,17 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() - await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax') + await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the // key value lands in the harness home's .env, the dormant route // registers, and the topology frame invalidates the page into the row. - await expect.poll(async () => dialog.getByLabel('API 密钥').count(), { timeout: 10_000 }).toBe(0) + await expect.poll( + async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(0) + await dialog.getByRole('img', { name: 'API 密钥已配置' }).waitFor({ timeout: 10_000 }) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') @@ -138,6 +146,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The editor closes back to the row; the fold's write merged into the // stored profile beside the reference. await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('reasoning: high') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 2c885817f1..4f861cd8a8 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -13,9 +13,11 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - status: 已保存 minimax-cn。 - list: - listitem: - text: minimax-cn + - img "API 密钥已配置" - button "编辑 minimax-cn": 编辑 - button "删除 minimax-cn": 删除 - button "添加提供方": diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 3eaef94eef..1438c94822 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -16,6 +16,7 @@ - list: - listitem: - text: DeepSeek + - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 - textbox "API 密钥": diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index b34caf8138..4b622ca023 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: 6ae0dd9d43c19f2a4350386104cf328d4d4a65d3 -README.zh.md: 77e2dcfb98ac3ac12a5ecb6975487b8159178937 +README.md: fdbd758e81e9631bf607797bfe2c2385c2b89ac6 +README.zh.md: 44498d728b4e292c717fa59c466a261b69cfa24b diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 6ae0dd9d43..fdbd758e81 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 77e2dcfb98..44498d728b 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index 6b87dbefe3..0615a9ec25 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -38,6 +38,13 @@ color: var(--dsw-alias-state-warn-label); } +.savedNotice { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-success-primary); +} + .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ @@ -65,6 +72,13 @@ gap: 10px; } +.rowIdentity { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .rowName { font-size: 14px; line-height: 22px; @@ -72,6 +86,23 @@ color: var(--dsw-alias-label-primary); } +.credentialDot { + box-sizing: border-box; + display: inline-block; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.credentialDotConfigured { + background: var(--dsw-alias-state-success-primary); +} + +.credentialDotMissing { + background: var(--dsw-alias-state-error-primary); +} + .rowActions { display: inline-flex; align-items: center; diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 54b0db3c38..3abdea3a61 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -1,10 +1,11 @@ /** * Models settings section: the provider rows joined from the configurable * directory, settings namespaces, and credential states, with one editor - * card at a time. A whole-section provider without a configured key (the - * unconfigured DeepSeek posture) renders as its open setup card instead of a - * row; the add flow is a card carrying the dormant-provider select. Every - * mutation writes through the wire, while a provider removal first requires + * card at a time. Rows expose only confirmed API-key state through accessible + * solid configured or missing dots. A whole-section provider without a + * configured key (the unconfigured DeepSeek posture) renders as its open setup + * card instead of a row; the add flow is a card carrying the dormant-provider + * select. Every mutation writes through the wire, while a provider removal first requires * confirmation; the page re-renders from pushed invalidations or the * post-apply reload. */ @@ -149,11 +150,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined) const [deleting, setDeleting] = useState(false) const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined) + const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined) - const closeEditor = (changed: boolean): void => { + const closeEditor = (changed: boolean, target: ProviderIdentity): void => { setEditing(undefined) setAdding(false) - if (changed) void controller.load() + if (changed) { + setSavedTarget(target) + void controller.load() + } } const closeDelete = (): void => { @@ -202,6 +207,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { <h2 className={styles['title']}>{t('title')}</h2> <p className={styles['intro']}>{t('intro')}</p> {!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null} + {savedTarget === undefined + ? null + : ( + <p className={styles['savedNotice']} role="status" aria-live="polite"> + {providerCopy(t('savedProvider'), savedTarget)} + </p> + )} <ul className={styles['rows']}> {configured.map((row) => { const target = targetOf(row) @@ -221,22 +233,51 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, target) }} /> </li> ) } const open = !adding && editing?.provider === row.entry.provider + const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true + const credentialMissing = !credentialConfigured + && row.apiKeyEnv !== undefined + && row.credential?.configured === false return ( <li key={row.entry.provider} className={styles['rowCard']}> <div className={styles['rowHead']}> - <span className={styles['rowName']}>{row.entry.displayName}</span> + <span className={styles['rowIdentity']}> + <span className={styles['rowName']}>{row.entry.displayName}</span> + {credentialConfigured + ? ( + <span + className={`${styles['credentialDot']} ${styles['credentialDotConfigured']}`} + role="img" + aria-label={t('credentialConfigured')} + title={t('credentialConfigured')} + /> + ) + : credentialMissing + ? ( + <span + className={`${styles['credentialDot']} ${styles['credentialDotMissing']}`} + role="img" + aria-label={t('credentialMissing')} + title={t('credentialMissing')} + /> + ) + : null} + </span> <span className={styles['rowActions']}> <button type="button" className={styles['secondaryButton']} aria-label={providerCopy(t('editProvider'), target)} - onClick={() => { setAdding(false); setEditing(open ? undefined : target) }} + onClick={() => { + setSavedTarget(undefined) + setAdding(false) + setEditing(open ? undefined : target) + }} > {t('edit')} </button> @@ -247,7 +288,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { className={styles['dangerButton']} aria-label={providerCopy(t('removeProvider'), target)} disabled={!state.writable} - onClick={() => { setDeleteFailure(undefined); setDeleteTarget(target) }} + onClick={() => { + setSavedTarget(undefined) + setDeleteFailure(undefined) + setDeleteTarget(target) + }} > {t('remove')} </button> @@ -265,7 +310,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, target) }} /> ) : null} @@ -305,7 +350,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, addTarget) }} /> </div> ) @@ -318,6 +363,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const first = addable[0] /* v8 ignore next -- the button is disabled while nothing is addable */ if (first === undefined) return + setSavedTarget(undefined) setAdding(true) setEditing(targetOf(first)) }} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index d85a3dd964..6faa8ab2d1 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -20,6 +20,9 @@ export const en = { cancel: 'Cancel', apply: 'Apply', applying: 'Applying…', + savedProvider: 'Saved {provider}.', + credentialConfigured: 'API key configured', + credentialMissing: 'API key missing', readOnly: 'The settings document is read-only in this deployment.', loadFailed: 'Loading the provider directory failed', conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.', @@ -85,6 +88,9 @@ export const zh: typeof en = { cancel: '取消', apply: '保存', applying: '保存中…', + savedProvider: '已保存 {provider}。', + credentialConfigured: 'API 密钥已配置', + credentialMissing: 'API 密钥缺失', readOnly: '当前部署的设置文档为只读。', loadFailed: '加载提供方目录失败', conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 29600642a2..4d6ca68670 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -213,9 +213,36 @@ describe('ModelsSection', () => { expect(screen.getByText('openai')).toBeTruthy() expect(screen.queryByText('Active')).toBeNull() expect(screen.queryByText('Inactive')).toBeNull() + const configured = screen.getByRole('img', { name: en.credentialConfigured }) + expect(configured.getAttribute('title')).toBe(en.credentialConfigured) + expect(configured.className).toContain('credentialDotConfigured') + expect(configured.closest('li')?.textContent).toContain('openai') + expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull() expect(screen.getByText(en.add)).toBeTruthy() }) + it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => { + const { face } = scriptedFace() + face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ + credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), + }))) + const controller = new ModelsSettingsStore(face as unknown as WireFace) + await controller.load() + render(<ModelsSection + controller={controller} + useSnapshot={bindSnapshotSelector(controller.store)} + api={face as never} + t={t} + />) + + const missing = screen.getByRole('img', { name: en.credentialMissing }) + expect(missing.getAttribute('title')).toBe(en.credentialMissing) + expect(missing.className).toContain('credentialDotMissing') + expect(missing.closest('li')?.textContent).toContain('openai') + expect(screen.queryByRole('img', { name: en.credentialConfigured })).toBeNull() + expect(screen.getByText('zombie').closest('li')?.querySelector('[role="img"]')).toBeNull() + }) + it('turns the setup card into a row once the credential reports configured', async () => { const { face } = await mountSection() face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ @@ -286,6 +313,11 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) }) expect(update).not.toHaveBeenCalled() await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) }) + expect((await screen.findByRole('status')).textContent).toBe( + providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }), + ) + fireEvent.click(screen.getByText(en.add)) + expect(screen.queryByRole('status')).toBeNull() }) it('applies customized deepseek fields as path ops', async () => { @@ -943,6 +975,7 @@ describe('ModelsSection', () => { fireEvent.change(key, { target: { value: 'sk-live' } }) fireEvent.click(screen.getByText(en.apply)) await screen.findByText(/shadowed by the read-only environment/) + expect(screen.queryByRole('status')).toBeNull() }) it('locks the key input when the launch environment provides the credential', async () => { From a667d2cd64fcc213e97d7c12c2aaf6f3e8c6c0b0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 17:21:13 +0800 Subject: [PATCH 135/190] fix(web): address skill row review feedback --- .../2026-08-06-web-skill-tool-row.i18n.yaml | 4 +- .../feature/2026-08-06-web-skill-tool-row.md | 6 +-- .../2026-08-06-web-skill-tool-row.zh.md | 6 +-- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 43 +++++++++------ .../client/connection/src/client/index.ts | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../client/session-history/history-fold.ts | 16 ++++-- .../src/client/sessions/conversation.ts | 6 +-- .../runtime/src/client/sessions/session.ts | 12 +++-- .../src/client/sessions/transcript-adapter.ts | 26 +++++++-- packages/client/runtime/tests/fake-api.ts | 4 +- .../client/runtime/tests/history-fold.spec.ts | 14 +++++ packages/client/runtime/tests/session.spec.ts | 17 ++++++ .../runtime/tests/transcript-adapter.spec.ts | 16 ++++++ .../src/client/chat/ToolRow.tsx | 13 +---- .../client/contract/terminal-card-model.ts | 11 ++-- .../src/client/toolviews/bash-sample.tsx | 6 +-- .../ui-conversation/tests/chat-view.spec.tsx | 4 +- .../client/ui-primitives/src/icons/index.tsx | 7 +++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- .../client/ui-skill/src/client/SkillRow.tsx | 31 +++++------ packages/client/ui-skill/src/invariant.ts | 7 +-- .../ui-skill/tests/browser-plugin.spec.ts | 44 ++++++++------- .../client/ui-skill/tests/skill-row.spec.tsx | 6 +-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 54 +++++++++++++++---- packages/host/apiproxy/src/api/index.ts | 2 +- .../host/apiproxy/src/api/sessions.schema.ts | 12 ++++- packages/host/apiproxy/src/api/sessions.ts | 18 +++++-- .../apiproxy/tests/api-proxy-view.spec.ts | 41 ++++++++++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 10 +++- 39 files changed, 326 insertions(+), 142 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml index 8186444a8d..237338a7e6 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-web-skill-tool-row.md -2026-08-06-web-skill-tool-row.md: b1d76c411d7ccc839616ddcce9fee18716489bf5 -2026-08-06-web-skill-tool-row.zh.md: c16a9b84d75c641b0fdd8778ff56c331c2c81546 +2026-08-06-web-skill-tool-row.md: bebcf658de33d133ffea8eb190fb4e8e63bf82ff +2026-08-06-web-skill-tool-row.zh.md: 9377829aab1cb6b347cb837dafe7e7e4afb63868 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md index b1d76c411d..bebcf658de 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -14,16 +14,16 @@ The Web transcript renders `skill` calls through the generic fallback row, so a The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. -The row derives every visible value from the logged call/result slice. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. +The row derives every visible value from the logged call/result slice. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. Because a history page can contain a `tool/result` after its `tool/call` fell outside the window, the generic `HistoryEntry` envelope now carries the paired call's name, exact arguments JSON, and event time on result entries. The Host derives this transient annotation and the result render intent from the complete log; the runtime prefers an in-window call and otherwise materializes the same `ToolResultNode.call` and `callTime` from the annotation. An orphan result still has `call: null`, and a call-side render intent remains unavailable when its event is outside the page. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. ## Alternatives considered - Keep the generic tool row and add only a `skill` color selector in `ui-conversation`. This leaves the redundant input envelope and generic expanded body in place, and makes the conversation package own a domain-specific visual rule. -- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool without changing the wire contract, so a new cross-boundary presentation value adds protocol and snapshot surface without enabling another consumer. +- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool; the cross-page fix belongs to the generic history pairing envelope used by every tool rather than a skill-specific presentation value. - Export the conversation package's private `ToolRow` component for reuse. Client packages intentionally expose contracts rather than cross-package components; exporting it would couple independent feature packages to conversation implementation details. ## Consequences `ui-skill` now depends on the public conversation toolview contract, locale and primitive packages, and React in addition to its reference-source dependencies. It owns a small copy of the disclosure-row chrome, so future global interaction changes must update this registrant alongside the Bash sample and conversation rows. -Cold replay stays deterministic when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format. +Cold replay stays deterministic across pagination and when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. The generic pairing annotation also prevents other keyed tool rows and result presenters from changing identity at a page boundary without persisting duplicate data. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md index c16a9b84d7..9377829aab 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -14,16 +14,16 @@ Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使 收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 -该行的所有可见值均派生自已记录的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 +该行的所有可见值均派生自已记录的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。由于 history 页可能包含 `tool/result`,而与之配对的 `tool/call` 已落在窗口外,通用 `HistoryEntry` envelope 现在会在结果条目上携带配对调用的名称、精确的 arguments JSON 和事件时间。Host 从完整日志派生这份瞬时注解和结果渲染意图;runtime 优先使用窗口内调用,否则从该注解物化出相同的 `ToolResultNode.call` 和 `callTime`。无配对结果仍为 `call: null`;调用事件位于页面外时,调用侧渲染意图仍不可用。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 ## 考虑过的替代方案 - 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。 -- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 无需更改协议契约即可识别该工具,因此新的跨边界呈现值只会增加协议与快照表层,却没有为其他消费方提供新能力。 +- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 已经能够识别该工具;跨页修复属于所有工具共用的通用 history 配对 envelope,而不是 skill 专用的呈现值。 - 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。 ## 后果 除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome,因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。 -即使已安装的 skill 目录发生变化,冷回放仍具有确定性;在用户显式展开指令前,transcript 保持紧凑。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 +无论跨越分页,还是已安装的 skill 目录发生变化,冷回放都保持确定性;在用户显式展开指令前,transcript 保持紧凑。通用配对注解还可防止其他键控工具行和结果 presenter 在分页边界改变身份,同时无需持久化重复数据。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 6f29b2dda0..de15a9c67f 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,7 @@ export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, HistoryToolCall, ToolEventView, DirectoryEntry, DirectoryListing, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 20af221f2d..5a7367acd0 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -29,7 +29,7 @@ import type { import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { - ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, + ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HistoryToolCall, HostFrame, MuxFrame, RpcReceipt, ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' @@ -661,25 +661,33 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR } } -/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */ +/** Full-log tool/result pair used by the fixture history envelope and presenter mirror. */ +function pairedHistoryCall(event: SessionEvent, log: readonly SessionEvent[]): HistoryToolCall | undefined { + if (event.type !== 'tool/result') return undefined + const callId = String(event.data.message.source.callId) + for (let i = log.length - 1; i >= 0; i--) { + const candidate = log[i] + /* v8 ignore next -- dense-array guard: i stays within [0, log.length), + so the undefined arm needs a sparse log no code path builds. */ + if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) { + return { name: candidate.data.name, arguments: candidate.data.arguments, time: candidate.time } + } + } + return undefined +} + +/** Host-side viewFor mirror: tool/call presents from its own args; tool/result uses its full-log pair. */ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined { if (event.type === 'tool/call') { const view = presentCall(event.data.name, event.data.arguments) return view === undefined ? undefined : { for: 'call', view } } if (event.type === 'tool/result') { - const callId = String(event.data.message.source.callId) - for (let i = log.length - 1; i >= 0; i--) { - const candidate = log[i] - /* v8 ignore next -- dense-array guard: i stays within [0, log.length), - so the undefined arm needs a sparse log no code path builds. */ - if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) { - const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('') - const view = presentResult(candidate.data.name, candidate.data.arguments, resultText) - return view === undefined ? undefined : { for: 'result', view } - } - } - return undefined // cross-page unpaired: documented default + const call = pairedHistoryCall(event, log) + if (call === undefined) return undefined + const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('') + const view = presentResult(call.name, call.arguments, resultText) + return view === undefined ? undefined : { for: 'result', view } } return undefined } @@ -1044,7 +1052,12 @@ function pageOf( } const events = log.slice(start, end).map((event): HistoryEntry => { const view = viewFor(event, log) - return view === undefined ? { event } : { event, view } + const call = pairedHistoryCall(event, log) + return { + event, + ...view === undefined ? {} : { view }, + ...call === undefined ? {} : { call }, + } }) return { events, hasMore: start > 0 } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 67b47b06c6..83e9722a49 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,7 +13,7 @@ import { isLoopbackHostname } from '../loopback-hostname.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, HistoryToolCall, ToolEventView, DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 23c867e4c0..ef94a8834c 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: 8ac29a4258bbd7456b20c61e547d48c570e84d27 -README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d +README.md: 3d981392ce0314f41fe84bc1adb2b9484a6a5989 +README.zh.md: c05bdb6ebb33c0ffa47e2b54fb1b3d9d25f2fa6d diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 8ac29a4258..3d981392ce 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -34,7 +34,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. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `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). +`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. A paged `tool/result` first pairs against an in-window `tool/call`, then against the Host-carried complete-log call annotation; `ToolResultNode.call` is null only for a truly orphaned durable result, so a page boundary cannot change keyed toolview dispatch, argument-derived labels, or duration. The call-side render intent remains null when its event is outside the window, while the result intent is already computed by the Host from the complete pair. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `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). 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 0e065e43ec..c05bdb6ebb 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`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` 与本程序的冲突)。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。分页得到的 `tool/result` 会先与窗口内的 `tool/call` 配对,再与 Host 携带的完整日志调用注解配对;只有持久结果确实没有配对调用时,`ToolResultNode.call` 才为 null,因此分页边界无法改变键控 toolview 分派、由参数派生的标签或耗时。调用事件位于窗口外时,调用侧渲染意图仍为 null,而结果侧渲染意图已经由 Host 基于完整配对计算完成。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`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` 与本程序的冲突)。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 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 d792fd2b76..83a09d3163 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -362,7 +362,8 @@ export function projectConversationHistory( let contextGeneration = 0 for (const [index, event] of events.entries()) { - const view = entries[index]?.view + const entry = entries[index] + const view = entry?.view if (event.type === 'tool/call') { callIndex.set(String(event.data.callId), { name: event.data.name, @@ -370,8 +371,17 @@ export function projectConversationHistory( time: event.time, callView: view?.for === 'call' ? view.view : null, }) - } else if (event.type === 'tool/result' && view?.for === 'result') { - resultViews.set(event.seq, view.view) + } else if (event.type === 'tool/result') { + const callId = String(event.data.message.source.callId) + if (!callIndex.has(callId) && entry?.call !== undefined) { + callIndex.set(callId, { + name: entry.call.name, + argsRaw: entry.call.arguments, + time: entry.call.time, + callView: null, + }) + } + if (view?.for === 'result') resultViews.set(event.seq, view.view) } if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { contextGeneration++ diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index d24b963d6b..14bd0dc9ed 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -155,16 +155,16 @@ export interface TurnErrorNode { code?: string } -/** A tool result paired (when in-window) with its call head. */ +/** A tool result paired with its durable call head when the Host can resolve it. */ export interface ToolResultNode { kind: 'tool-result' seq: number /** Unix epoch ms from the tool/result session event. */ time: number callId: string - /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ + /** Call head from the window or history envelope; null only when the durable log has no pair (card head shows callId). */ call: { name: string; argsRaw: string } | null - /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ + /** Unix epoch ms of the paired tool/call; null when the durable log has no pair. */ callTime: number | null content: readonly ContentBlock[] isError: boolean diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 776f4494fd..e663af8bf6 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, + HistoryEntry, HistoryToolCall, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): @@ -85,6 +85,8 @@ export class Session implements SessionFace { /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view). * Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */ private views: (ToolEventView | undefined)[] = [] + /** Host-carried call metadata aligned with result entries when the call event is outside the page. */ + private historyCalls: (HistoryToolCall | undefined)[] = [] private baseSeq = 0 private hasMore = false private openState: OpenState = 'cold' @@ -381,10 +383,11 @@ export class Session implements SessionFace { } this.events = [...older.map(e => e.event), ...this.events] this.views = [...older.map(e => e.view), ...this.views] + this.historyCalls = [...older.map(e => e.call), ...this.historyCalls] /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ this.baseSeq = older[0]?.event.seq ?? this.baseSeq this.hasMore = result.value.hasMore - this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head) + this.transcript.reset(this.events, this.views, this.historyCalls) // prepend forces a rebuild (the window grew at the head) this.rebuildDerivedFromWindow() } catch (error) { console.error('[web-runtime] loadOlder failed:', error) @@ -411,6 +414,7 @@ export class Session implements SessionFace { this.openError = null this.events = [] this.views = [] + this.historyCalls = [] this.baseSeq = 0 // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. @@ -644,9 +648,10 @@ export class Session implements SessionFace { private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) + this.historyCalls = entries.map(e => e.call) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore - this.transcript.reset(this.events, this.views) + this.transcript.reset(this.events, this.views, this.historyCalls) this.rebuildDerivedFromWindow() if (projections !== undefined) this.projections.seed(projections) const buffered = this.liveBuffer @@ -661,6 +666,7 @@ export class Session implements SessionFace { if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop this.events.push(event) this.views.push(view) + this.historyCalls.push(undefined) this.transcript.append(event, view) this.handoffPendingSteering(event) this.applyEventSideEffects(event, view) diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 306571b2bf..b1d952b804 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -19,7 +19,9 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' // `sessions: ISessions` (TS2717, the one-program-per-side rule in // docs/development.md). import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { + HistoryToolCall, ToolCallView, ToolEventView, ToolResultView, +} from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' import { contextForm, contextProvenance } from './context-provenance.ts' @@ -213,8 +215,13 @@ export class TranscriptAdapter { * and re-project the transcript. * @param events - the new window contents (seq-ascending). * @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events). + * @param calls - host-carried result pairs aligned with `events` by index. */ - reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void { + reset( + events: readonly SessionEvent[], + views?: readonly (ToolEventView | undefined)[], + calls?: readonly (HistoryToolCall | undefined)[], + ): void { this.rev++ this.eventIndex = new Map() this.callIdx = new Map() @@ -228,7 +235,7 @@ export class TranscriptAdapter { /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ if (event === undefined) continue this.eventIndex.set(event.seq, event) - this.indexCall(event, views?.[i]) + this.indexCall(event, views?.[i], calls?.[i]) this.indexCommand(event) if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) indexAssistantStepTiming(this.stepTimings, event) @@ -338,9 +345,20 @@ export class TranscriptAdapter { return true } - private indexCall(event: SessionEvent, view?: ToolEventView): void { + private indexCall(event: SessionEvent, view?: ToolEventView, pairedCall?: HistoryToolCall): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) + const callId = String(event.data.message.source.callId) + if (!this.callIdx.has(callId) && pairedCall !== undefined) { + this.callIdx.set(callId, { + name: pairedCall.name, + argsRaw: pairedCall.arguments, + turn: event.data.turn, + step: event.data.step, + time: pairedCall.time, + callView: null, + }) + } return } if (event.type !== 'tool/call') return diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index e50574d102..b13e27f3c4 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { - ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame, + ClientResponse, CommandDescriptor, HistoryEntry, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' @@ -68,7 +68,7 @@ export class FakeApiClient implements IApiClient { onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> = + => Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>> = () => Promise.resolve(ok({ events: [], hasMore: false })) onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({ diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 083bdc3566..f9b40bdb7c 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -53,6 +53,20 @@ describe('projectConversationHistory', () => { }]) }) + it('projects a paged tool result from its host-carried call pair', () => { + const result = ev.toolResult(50, 3, 'outside-call', '已加载 skill') + const projection = projectConversationHistory([{ + event: result, + call: { name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }, + }]) + expect(projection.eventNodes).toMatchObject([{ + kind: 'tool-result', + call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, + callTime: 40, + callView: null, + }]) + }) + it('projects a high-sequence history window without synthesizing its unloaded prefix', () => { const baseSeq = 400_000 const events = [ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index c288c044ee..02753fe09e 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -53,6 +53,23 @@ describe('open', () => { expect(snapshot.turnEnds.get(3)).toBe(15) }) + it('installs host-carried call metadata for a result-only tail page', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok({ + events: [{ + event: ev.toolResult(50, 3, 'outside-call', '已加载 skill'), + call: { name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }, + }], + hasMore: true, + })) + await session.open() + expect(session.getSnapshot().nodes).toMatchObject([{ + kind: 'tool-result', + call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, + callTime: 40, + }]) + }) + it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => { const { api, session } = makeSession() await Promise.all([session.open(), session.open()]) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index 031acf1780..99b4cdf261 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -365,6 +365,22 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null }) }) + it('materializes a paged tool-result from its host-carried call pair', () => { + const adapter = new TranscriptAdapter() + adapter.reset( + [ev.toolResult(50, 3, 'outside-call', '已加载 skill')], + [undefined], + [{ name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }], + ) + expect(adapter.nodes()[0]).toMatchObject({ + kind: 'tool-result', + callId: 'outside-call', + call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, + callTime: 40, + callView: null, + }) + }) + it('materializes a tool-result error field when present', () => { const adapter = new TranscriptAdapter() adapter.reset([ diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 71cf69cc5f..ac48604fc9 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -23,7 +23,7 @@ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { - CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, + CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -99,15 +99,6 @@ export interface ToolRowProps { inspect?: (() => void) | undefined } -/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */ -function IconInspect() { - return ( - <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> - <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> - </svg> - ) -} - /** Leading-slot state substitution: the tool icon yields to the terminal state * semantic (error = red, interrupted = amber halo). Running keeps the icon — * the row sweep (CSS on data-state) carries the in-flight signal. */ @@ -319,7 +310,7 @@ export function ToolRow({ className={css.inspectButton} onClick={inspect} > - <IconInspect /> + <IconInspectOutline12 /> Inspect </button> )} diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index 8a0c887990..b1c4cbe757 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -168,11 +168,12 @@ function collapse(body: string, rooted: boolean, separator = '/'): string { * returns a generic fenced card for an execution error or a background * start, whose text and error styling the generic path preserves. * - * Window truncation can drop the call head from a settled result (see - * `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal - * result with no call side. That still renders: the command falls back to the - * result view's replacement title, then to an empty command (the prompt line - * draws bare), and the prompt shows no cwd. + * Window truncation can drop the call event and its call-side view from a + * settled result (see `ToolResultNode.callView` in dsh-client-runtime), leaving + * a terminal result with no presentation call side even though the history + * envelope preserves its name and arguments. That still renders: the command + * falls back to the result view's replacement title, then to an empty command + * (the prompt line draws bare), and the prompt shows no cwd. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param sessionCwd - the session workspace root, which resolves an omitted or * relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved. diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 54e021639f..adf4ac4355 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -17,7 +17,7 @@ import { useState, type KeyboardEvent } from 'react' import type { Context } from 'cordis' import clsx from 'clsx' import { - IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock, + IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolRowProps } from '../contract/slots.ts' @@ -153,9 +153,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: )} {inspect !== undefined && ( <button type="button" className={css.inspectButton} onClick={inspect}> - <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> - <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> - </svg> + <IconInspectOutline12 /> Inspect </button> )} diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b7ca8dd149..e83c82130d 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -282,12 +282,12 @@ describe('chat-flow derivation', () => { }) describe('ChatView', () => { - it('a windowless tool result (call head truncated) renders with an empty tool name', () => { + it('an orphan tool result renders through the generic fallback', () => { const h = makeHarness({ nodes: [{ ...toolResult(3, 'w1'), call: null }], }) const view = render(<h.ChatView {...h.props} />) - // classifyTool('') → others; the summary slot falls back to the callId. + // No durable call exists for this id, so the summary falls back to callId. expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull() expect(view.getByText('w1')).toBeTruthy() }) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index b0b76e164b..5b99a0e71c 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -750,6 +750,13 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => ( </svg> ) +/** inspect_outline_12 (shared tool-row trajectory affordance glyph) */ +export const IconInspectOutline12 = ({ size = 12, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> + <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> + </svg> +) + /** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */ export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => ( <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 92f0d3cc37..678eb9754e 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (46 deepsuite + 17 figma extracts + two hand-authored product glyphs)', () => { - expect(iconNames.length).toBe(65) + it('exports the full P-I set (46 deepsuite + 17 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(66) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index d23f68ee85..5c50d22b89 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-skill/README.md -README.md: 2280c9302dbc46cff723752f88c47940f98417d5 -README.zh.md: 0e9344ff63139f77461b02b48e18b0e94e54c223 +README.md: ba9f1faae0f70a0f7bed4641e02703cc26bcb692 +README.zh.md: f8210a885d201cbdc89d7a34704a819e80463d2c diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 2280c9302d..ba9f1faae0 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -10,7 +10,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou ## Skill tool row -The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, never from the current catalog, so cold replay remains stable even when installed skills or their descriptions change. +The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, using the history envelope's host-carried durable pair when pagination left the call event outside the window; it never reads the current catalog, so cold replay remains stable across page cuts and when installed skills or their descriptions change. ## Model Experience diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 0e9344ff63..f8210a885d 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -10,7 +10,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## skill 工具行 -浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,冷回放仍保持稳定。 +浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段;分页将调用事件留在窗口外时,则使用 history envelope 中由 Host 携带的持久配对。该行绝不读取当前 skill 目录,因此冷回放在跨分页时,以及已安装的 skill 或其描述发生变化时均保持稳定。 ## 模型体验 diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index be1084ec39..076da55d52 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -4,7 +4,7 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react' import { - IconChevronDownOutline14, IconSkillOutline16, StateDot, + IconChevronDownOutline14, IconInspectOutline12, IconSkillOutline16, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -45,7 +45,8 @@ function skillName(argsRaw: string, callId: string): string { return argsRaw === '' ? callId : firstLine(argsRaw) } -/** Flatten the durable result exactly like the generic row's text fallback. */ +/** Flatten durable result blocks under the generic tool-row text contract. + * Keep aligned with ui-conversation's contract/tool-call-model.ts `resultText`. */ function resultText(block: ToolRowProps['block']): string | null { if (!('kind' in block)) return null const parts: string[] = [] @@ -108,15 +109,6 @@ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null } } -/** Inspect affordance glyph shared with the transcript's other tool rows. */ -function IconInspect() { - return ( - <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> - <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" /> - </svg> - ) -} - /** * Render one `skill` tool call as an accent summary and instructions disclosure. * @param props - keyed toolview payload plus the skill locale seat. @@ -129,7 +121,6 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { const open = expanded && expandable const status = stateStatus(model.state, t) const summary = model.errorSummary ?? model.name - const ariaLabel = status === null ? `Skill ${summary}` : `${status} Skill ${summary}` const toggleExpand = (): void => { setExpanded(value => !value) } @@ -138,18 +129,20 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { event.preventDefault() toggleExpand() } + const disclosureProps = expandable ? { + role: 'button' as const, + tabIndex: 0, + 'aria-expanded': open, + onClick: toggleExpand, + onKeyDown: toggleFromKeyboard, + } : {} const leading = disclosureLeading(model.state, open, expandable) return ( <div className={css.card} data-tool="skill" data-state={model.state}> <div className={css.row} data-expandable={expandable || undefined} - role={expandable ? 'button' : undefined} - tabIndex={expandable ? 0 : undefined} - aria-expanded={expandable ? open : undefined} - aria-label={expandable ? ariaLabel : undefined} - onClick={expandable ? toggleExpand : undefined} - onKeyDown={expandable ? toggleFromKeyboard : undefined} + {...disclosureProps} > <span className={css.leading}>{leading}</span> {status !== null ? <span className={css.visuallyHidden}>{status}</span> : null} @@ -167,7 +160,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { </section> {inspect !== undefined ? ( <button type="button" className={css.inspectButton} onClick={inspect}> - <IconInspect /> + <IconInspectOutline12 /> Inspect </button> ) : null} diff --git a/packages/client/ui-skill/src/invariant.ts b/packages/client/ui-skill/src/invariant.ts index 241482a306..9246466cd1 100644 --- a/packages/client/ui-skill/src/invariant.ts +++ b/packages/client/ui-skill/src/invariant.ts @@ -15,9 +15,10 @@ export const name = 'client-ui-skill-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a single slash-source registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. + * No runtime invariant: the slash source, locale dictionaries, and keyed + * toolview are registry-owned registrations whose disposal is proven by the + * HMR-safety spec. They emit no cordis events and own no cross-plugin mutable + * state. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 3febb36efb..9b047a3713 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -14,6 +14,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import { apply, inject } from '../src/client/index.ts' @@ -25,33 +26,28 @@ type ListResult = | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> -interface PresentationRegistration { - name: string - key?: string - locale?: string -} - interface PresentationCapture { - registration?: PresentationRegistration - component?: unknown + slots: SlotsService dictionaries: Array<{ namespace: string; dictionaries: unknown }> + localeDisposed: boolean } /** Provide the presentation registries and capture the plugin's registrations. */ function providePresentation(ctx: Context): PresentationCapture { - const capture: PresentationCapture = { dictionaries: [] } + const slots = new SlotsService(ctx) + slots.register({ + name: 'root', + children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } }, + } as never, () => null) + const capture: PresentationCapture = { + slots, + dictionaries: [], + localeDisposed: false, + } ctx.provide('locale', { register(namespace: string, dictionaries: unknown) { capture.dictionaries.push({ namespace, dictionaries }) - return () => {} - }, - }) - ctx.provide('slots', { - inject(_name: string, factory: () => unknown) { factory() }, - register(registration: PresentationRegistration, component: unknown) { - capture.registration = registration - capture.component = component - return () => {} + return () => { capture.localeDisposed = true } }, }) return capture @@ -110,10 +106,10 @@ describe('apply', () => { ctx.provide('sessions', { subagentAddress: () => undefined }) const presentation = providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() - expect(presentation.registration).toEqual({ - name: 'conversation.chat.toolview', key: 'skill', locale: 'skill', - }) - expect(presentation.component).toBe(SkillToolRow) + const entry = presentation.slots.entries('conversation.chat.toolview')[0] + expect(entry?.options).toMatchObject({ key: 'skill' }) + expect(entry?.locale).toBe('skill') + expect(entry?.component).toBe(SkillToolRow) expect(presentation.dictionaries).toEqual([{ namespace: 'skill', dictionaries: { zh: { @@ -138,7 +134,7 @@ describe('apply', () => { ctx.provide('sessions', {}) await ctx.plugin(SlashService).await() ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) - providePresentation(ctx) + const presentation = providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService @@ -153,6 +149,8 @@ describe('apply', () => { // …and fiber teardown releases it. await fiber.dispose() expect(() => slash.registerSource(rival)).not.toThrow() + expect(presentation.slots.entries('conversation.chat.toolview')).toHaveLength(0) + expect(presentation.localeDisposed).toBe(true) }) }) diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.spec.tsx index 2dacf0a036..4143b4a7a2 100644 --- a/packages/client/ui-skill/tests/skill-row.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.spec.tsx @@ -53,7 +53,7 @@ describe('SkillRow', () => { it('renders a compact Bash-shaped summary and discloses the exact instructions', () => { const inspect = vi.fn() const view = render(<SkillRow {...props(settled(), inspect)} />) - const row = screen.getByRole('button', { name: 'Skill dsh-manage-issues' }) + const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' }) expect(row.getAttribute('aria-expanded')).toBe('false') expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok') expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('16') @@ -97,7 +97,7 @@ describe('SkillRow', () => { isError: true, error: { name: 'SkillError', code: 'missing' }, }))} />) - const row = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing resource' }) + const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' }) expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') expect(row.textContent).not.toContain('Check SKILL.md.') fireEvent.click(row) @@ -126,7 +126,7 @@ describe('SkillRow', () => { isError: true, error: { name: 'SkillError', code: 'missing' }, }))} />) - const errorRow = screen.getByRole('button', { name: 'skill 加载失败 Skill SkillError: missing' }) + const errorRow = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing' }) fireEvent.click(errorRow) expect(screen.getAllByText('SkillError: missing')).toHaveLength(2) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 8ee0d81334..22342f8a24 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 00b9ea075af7eb55041d48ceb3a1363e9de90397 -README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9 +README.md: 7f5d7b50cf86e251b73e2e67e38939827bd7eb13 +README.zh.md: 71b40929e065981c08a2d2b4fb1e941cbef687ff diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 00b9ea075a..7f5d7b50cf 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. -`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. +`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. A `tool/result` entry additionally carries its paired call's name, exact arguments JSON, and event time as a transient history annotation derived from the complete log, so a page cut cannot erase keyed toolview dispatch, argument-derived summaries, or duration. Result render intents use that same complete-log pair; an orphan result or malformed arguments still soft-fall to the generic presentation path. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e62e3fa99c..71b40929e0 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,7 +12,7 @@ 首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 -`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 +`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。`tool/result` 条目还会携带其配对调用的名称、精确的 arguments JSON 和事件时间,作为从完整日志派生的瞬时 history 注解,因此分页切分无法抹掉键控 toolview 分派、由参数派生的摘要或耗时。结果渲染意图使用完整日志中的同一配对;无配对结果或参数损坏时,仍会软降级到通用呈现路径。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 928ecc7b55..5b9511de93 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -26,7 +26,7 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, + ApiProxy, CredentialView, GoalRef, HistoryEntry, HistoryToolCall, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, @@ -408,9 +408,9 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues * Compute the render intent for a tool/call or tool/result event through the * presenters registered at this moment; every other event type gets none. A * result's presenter needs its call's parsed args — `argsFor` supplies them - * (live: the per-session call table; history: an in-page backscan), returning - * undefined when the pairing is unavailable (e.g. the call fell off the page), - * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: + * (live: the per-session call table; history: the full-log pairing index), + * returning undefined when the pairing is unavailable, which soft-falls to no + * view. Presenter or JSON.parse throws also soft-fall: * the client's documented default (generic JSON card) covers every miss. */ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined { @@ -442,10 +442,8 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => } /** - * Resolve a tool/result's call pairing by scanning a window of events backwards - * for the matching tool/call. Used by the history path (the page is the - * window — a cross-page pairing soft-falls to no view) and by live-path table - * misses after a reconnect-eviction. + * Resolve a tool/result's call pairing by scanning a live session backwards + * for the matching tool/call after the open-call table missed. */ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined { for (let i = events.length - 1; i >= 0; i--) { @@ -463,6 +461,34 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: return undefined } +/** Index durable call metadata once so every history result keeps its pair across page cuts. */ +function historyCallIndex(events: readonly SessionEvent[]): ReadonlyMap<string, HistoryToolCall> { + const calls = new Map<string, HistoryToolCall>() + for (const event of events) { + if (event.type !== 'tool/call') continue + calls.set(String(event.data.callId), { + name: event.data.name, + arguments: event.data.arguments, + time: event.time, + }) + } + return calls +} + +/** Parse one indexed history pair for a result presenter, soft-falling malformed arguments. */ +function historyArgs( + calls: ReadonlyMap<string, HistoryToolCall>, + callId: string, +): { name: string; args: unknown } | undefined { + const call = calls.get(callId) + if (call === undefined) return undefined + try { + return { name: call.name, args: JSON.parse(call.arguments) } + } catch { + return undefined + } +} + /** Render one detached history page through the same presenter path as ordinary history. */ function historyPage( ctx: Context, @@ -471,10 +497,18 @@ function historyPage( maxMessages: number | undefined, ): { events: HistoryEntry[]; hasMore: boolean } { const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) + const calls = historyCallIndex(events) return { events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) - return { event, ...view === undefined ? {} : { view } } + const view = viewFor(ctx, event, callId => historyArgs(calls, callId)) + const call = event.type === 'tool/result' + ? calls.get(String(event.data.message.source.callId)) + : undefined + return { + event, + ...view === undefined ? {} : { view }, + ...call === undefined ? {} : { call }, + } }), hasMore: page.hasMore, } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 4f10d92853..697e5bdeae 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -36,7 +36,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + HistoryEntry, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 9f9c4329e6..f47289e77b 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,7 +11,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + HistoryEntry, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' @@ -193,10 +193,18 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [ z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }), ]) as unknown as z.ZodType<ToolEventView> -/** One session.history item: the session event plus its optional host-computed tool view. */ +/** Paired tool/call metadata carried with a paged tool/result. */ +export const historyToolCallSchema: z.ZodType<Wire<HistoryToolCall>> = z.object({ + name: z.string(), + arguments: z.string(), + time: z.number(), +}) + +/** One session.history item: raw event plus optional host-computed tool annotations. */ export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({ event: sessionEventSchema, view: toolEventViewSchema.optional(), + call: historyToolCallSchema.optional(), }) as unknown as z.ZodType<Wire<HistoryEntry>> /** diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 18315eef19..2a6da96db9 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -26,14 +26,26 @@ declare module '@deepseek-ai/dsh-llm' { } } +/** Paired tool/call metadata carried beside a paged result whose call may be outside the page. */ +export interface HistoryToolCall { + /** Registered tool name used for keyed presentation dispatch. */ + name: string + /** Exact durable arguments JSON from the paired tool/call. */ + arguments: string + /** Unix epoch ms of the paired tool/call event. */ + time: number +} + /** - * One history page entry: the raw event plus the optional host-computed render - * intent (same semantics as the mux frame's `view` slot — a pagination-time - * derivation, never persisted). + * One history page entry: the raw event plus optional host-computed render + * intent and result pairing. Both annotations are pagination-time derivations, + * never persisted; `call` preserves a tool/result's identity when its call + * event lies outside this page. */ export interface HistoryEntry { event: SessionEvent view?: ToolEventView + call?: HistoryToolCall } /** diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..3b19a26b5e 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -231,9 +231,50 @@ describe('mux live view computation', () => { ])) expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } }) expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } }) + expect(byKey.get('tool/result:h-term')?.call).toEqual({ + name: 'term', arguments: '{"cmd":"ls"}', time: byKey.get('tool/call:h-term')?.event.time, + }) expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false) + expect('call' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false) expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false) + expect(byKey.get('tool/result:h-bad')?.call?.arguments).toBe('{broken') expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false) + expect(byKey.get('tool/result:h-plain')?.call?.name).toBe('plain') + }) + + it('carries a result pair and computes its view when the call is outside the history page', async () => { + const { ctx } = await harness() + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + session.append('turn/start', { turn: 1 }) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: CallId('cross-page'), name: 'term', arguments: '{"cmd":"tail"}', + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) + appendUserText(session, 'newer message cuts the page') + const result = session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: CallId('cross-page'), + content: [{ type: 'text', text: 'late result' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + + const response = await api.sessions.history({ + rpcId: RpcId('t-hist-cross-page'), + payload: { sessionId: session.id, maxMessages: 1 }, + }) + if (!response.result.ok) throw new Error('unreachable') + const entries = response.result.value.events + expect(entries.some(entry => entry.event.seq === call.seq)).toBe(false) + const entry = entries.find(candidate => candidate.event.seq === result.seq) + expect(entry).toMatchObject({ + call: { name: 'term', arguments: '{"cmd":"tail"}', time: call.time }, + view: { for: 'result', view: { card: 'terminal', output: 'done' } }, + }) }) it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..3a76dd9b07 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -190,10 +190,16 @@ describe('sessions domain schemas', () => { expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3) expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow() expect(sessionHistoryValueSchema.parse({ - events: [], + events: [{ + event: { type: 'tool/result', seq: 3, time: 30, data: {} }, + call: { name: 'skill', arguments: '{"name":"review"}', time: 20 }, + }], hasMore: false, modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }).hasMore).toBe(false) + })).toMatchObject({ + events: [{ call: { name: 'skill', arguments: '{"name":"review"}', time: 20 } }], + hasMore: false, + }) expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, From e43e4f187e2126806b21063869769d8bafdf7af0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 17:31:25 +0800 Subject: [PATCH 136/190] fix(web): satisfy provider model gates --- .../ui-models/src/client/ModelsSection.tsx | 60 ++++++++++++------- .../ui-models/tests/provider-form.spec.tsx | 18 +++++- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index b5a09021ff..b5a2801bf5 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -18,7 +18,7 @@ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { CustomProviderCard } from './CustomProviderCard.tsx' import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' -import { ProviderEditor } from './ProviderEditor.tsx' +import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -56,6 +56,26 @@ interface EditorTarget extends ProviderIdentity { credentialRef?: string } +/** Values that vary around the shared provider-editor rendering. */ +interface ProviderEditorRenderProps extends Pick< + ProviderEditorProps, + 'namespace' | 'api' | 't' | 'readOnly' | 'onClose' +> { + target: EditorTarget +} + +/** Render an editor for either the setup posture or an expanded provider row. */ +function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode { + return ( + <ProviderEditor + provider={target.provider} + displayName={target.displayName} + settingsPath={target.settingsPath} + {...props} + /> + ) +} + /** * Remove one user-added provider and its page-managed credential. Credential * removal comes first so a second-step failure leaves the provider row visible @@ -232,16 +252,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { // setup card IS its presence on the page. return ( <li key={row.entry.provider} className={styles['setupCard']}> - <ProviderEditor - provider={target.provider} - displayName={target.displayName} - namespace={namespace} - settingsPath={target.settingsPath} - api={api} - t={t} - readOnly={!state.writable} - onClose={(changed) => { closeEditor(changed, target) }} - /> + {renderProviderEditor({ + target, + namespace, + api, + t, + readOnly: !state.writable, + onClose: (changed) => { closeEditor(changed, target) }, + })} </li> ) } @@ -312,18 +330,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { </span> </div> {open - ? ( - <ProviderEditor - provider={target.provider} - displayName={target.displayName} - namespace={namespace} - settingsPath={target.settingsPath} - api={api} - t={t} - readOnly={!state.writable} - onClose={(changed) => { closeEditor(changed, target) }} - /> - ) + ? renderProviderEditor({ + target, + namespace, + api, + t, + readOnly: !state.writable, + onClose: (changed) => { closeEditor(changed, target) }, + }) : null} </li> ) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..367be642d0 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -142,7 +142,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) { t, } render(<ModelsSection {...injected} />) - return scripted + return { ...scripted, controller } } /** Open the editor of one configured row and expand its customized fold. */ @@ -862,4 +862,20 @@ describe('hand-declared providers', () => { await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() }) expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() }) + + it('reloads the section after creating a hand-declared provider', async () => { + const { controller, mutate } = await mountSection() + const load = vi.spyOn(controller, 'load') + + fireEvent.click(screen.getByRole('button', { name: en.customAdd })) + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + fireEvent.click(screen.getByText(en.create)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + await waitFor(() => { expect(load).toHaveBeenCalledOnce() }) + expect(screen.queryByText(en.customTitle)).toBeNull() + }) }) From ab94a2f7d6463ba640af5866c4b28908d5dde3b0 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 17:39:43 +0800 Subject: [PATCH 137/190] refactor(telemetry): centralize the default mode --- docs/config-catalog.md | 2 +- .../telemetry/session-telemetry-otel/src/index.ts | 12 ++++-------- .../session-telemetry-otel/tests/otel.spec.ts | 4 +++- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c386ee4e6b..46766c2640 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1193,7 +1193,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:83`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:79`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index f380d97549..36429448c6 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -46,12 +46,8 @@ export enum TelemetryMode { DISABLED = 'DISABLED', } -/** Supported session-sharing policies for runtime configuration validation. */ -export const TELEMETRY_MODES = [ - TelemetryMode.FULL, - TelemetryMode.FEEDBACK_ONLY, - TelemetryMode.DISABLED, -] as const +/** Default session-sharing policy for schema and direct construction. */ +export const DEFAULT_TELEMETRY_MODE = TelemetryMode.FULL const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log' @@ -59,7 +55,7 @@ const DROP_RECORD: TelemetryBackend['emit'] = () => {} /** Resolve the default and reject unknown runtime values before transport setup. */ function resolveMode(mode: TelemetryMode | undefined): TelemetryMode { - const resolved = mode ?? TelemetryMode.FULL + const resolved = mode ?? DEFAULT_TELEMETRY_MODE switch (resolved) { case TelemetryMode.FULL: case TelemetryMode.FEEDBACK_ONLY: @@ -109,7 +105,7 @@ export interface Config { * axiom (and silently drop every field not re-declared). */ export const Config: z<Config> = z.object({ - mode: z.union(TELEMETRY_MODES).default(TelemetryMode.FULL), + mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE), exporter: z.any(), processor: z.any(), }) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index f7b3a007c9..f95af8db16 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -13,7 +13,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import TelemetryOtel, { Config, TelemetryMode } from '../src/index.ts' +import TelemetryOtel, { Config, DEFAULT_TELEMETRY_MODE, TelemetryMode } from '../src/index.ts' interface Capture { headers: import('node:http').IncomingHttpHeaders @@ -330,6 +330,8 @@ describe('TelemetryOtel config fails loud', () => { expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>() expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>() expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>() + expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.FULL) + expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE) }) it.each([ From 84a6bae1c73d6550a6ab16d571679f54e252d5f1 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Thu, 6 Aug 2026 17:46:18 +0800 Subject: [PATCH 138/190] fix(web): drop the branch action from user and steering bubbles The branch control on user and consumed-steering bubbles could enable only when a completed turn ended with no node after the message at all, so readers saw a permanently unavailable control whose tooltip promised a state it could not reach. An enabled one would mislead anyway: a fork at a message seq cuts at the containing turn/end and takes the answer along, the opposite of the branch-to-re-ask reading a control on one's own bubble suggests. MessageItem loses its fork props, PendingSteeringBubble loses the showBranch special case, and messageBranchSeqs narrows to assistantBranchSeqs: only a completed turn's transcript tail that is the turn's own content-text assistant may fork. A steered turn keeps its fork point under the settled answer, because fork is a log-prefix cut and the steer is model-visible history the child inherits. Web aria goldens drop the user-bubble disabled-branch row and its hidden explanation text; the nested-subagent golden also loses the one enabled user-tail fork handle, a loss the decision note accepts. --- ...ions-require-completed-turn-tail.i18n.yaml | 4 +- ...ork-actions-require-completed-turn-tail.md | 2 + ...-actions-require-completed-turn-tail.zh.md | 2 + ...b-message-icon-actions-and-clock.i18n.yaml | 4 +- ...7-29-web-message-icon-actions-and-clock.md | 2 +- ...9-web-message-icon-actions-and-clock.zh.md | 2 +- ...r-bubbles-drop-the-branch-action.i18n.yaml | 6 ++ ...-06-user-bubbles-drop-the-branch-action.md | 27 ++++++++ ...-user-bubbles-drop-the-branch-action.zh.md | 27 ++++++++ apps/web/tests/message-actions.e2e.ts | 8 +-- .../snapshots/bash-abort-row/ui.expected.md | 3 - .../snapshots/code-mode-round/ui.expected.md | 3 - .../cordis-tool-round/ui.expected.md | 3 - .../snapshots/fresh-round-trip/ui.expected.md | 3 - .../lifecycle-chrome/reloaded.expected.md | 3 - .../live-interactions/cancel.expected.md | 3 - .../live-interactions/error-auth.expected.md | 3 - .../live-interactions/loading.expected.md | 3 - .../live-interactions/retry.expected.md | 3 - .../markdown-cjk-strong/ui.expected.md | 3 - .../snapshots/markdown-images/ui.expected.md | 3 - .../markdown-inline-code-links/ui.expected.md | 3 - .../snapshots/math-rendering/ui.expected.md | 3 - .../snapshots/message-actions/ui.expected.md | 6 -- .../plan-review/approved.expected.md | 3 - .../question-composer/answered.expected.md | 3 - .../queue-actions/collapsed.expected.md | 3 - .../queue-actions/editing.expected.md | 3 - .../queue-actions/preserved.expected.md | 3 - .../snapshots/queue-actions/ui.expected.md | 3 - .../seeded-history/command-row.expected.md | 3 - .../snapshots/seeded-history/ui.expected.md | 3 - .../snapshots/steering/mid-steer.expected.md | 3 - .../snapshots/steering/settled.expected.md | 6 -- .../subagent-conversation/nested.expected.md | 2 - .../subagent-conversation/ui.expected.md | 6 -- .../turn-tail-actions/running.expected.md | 3 - .../turn-tail-actions/settled.expected.md | 3 - .../snapshots/web-search-round/ui.expected.md | 3 - apps/web/tests/turn-tail-actions.e2e.ts | 5 +- .../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/ChatView.tsx | 6 +- .../src/client/chat/MessageIconActions.tsx | 8 +-- .../src/client/chat/MessageItem.tsx | 17 ++--- .../src/client/chat/chat-flow.ts | 18 +++--- .../tests/chat-branch-tails.spec.tsx | 64 +++++++++---------- .../ui-conversation/tests/chat-view.spec.tsx | 44 ++++++------- 49 files changed, 154 insertions(+), 199 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md create mode 100644 .agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml index 9ac8e49fe6..7f4b8c7824 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md -2026-08-02-message-fork-actions-require-completed-turn-tail.md: f2e7fd67b65a6ce4a86ba3f4405f78842be8f234 -2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 2c3feeaa3ef01dbde67faa73257520918996f9c8 +2026-08-02-message-fork-actions-require-completed-turn-tail.md: abdcbc79948c67619bb70a8a87741046f65b8838 +2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: a93b572c6db3c76ce3869747fd9a7660bf3ea395 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md index f2e7fd67b6..abdcbc7994 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md @@ -12,6 +12,8 @@ The Web conversation attached branch to the last assistant node with nonempty te `ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and enables branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node leaves branch unavailable on earlier messages. The unavailable control stays visible, focusable, and hoverable; `aria-disabled`, a tooltip, and `aria-describedby` explain the completed-tail requirement without sending a Host request. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. +The message-bubble half of this eligibility is superseded by the [user-bubble branch removal](../simplification/2026-08-06-user-bubbles-drop-the-branch-action.md): user and steering bubbles no longer render the control at all, so only content-assistant tails may fork; the assistant-side gate and its visible-but-unavailable presentation stand. + This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md index 2c3feeaa3e..a93b572c6d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md @@ -12,6 +12,8 @@ Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assist `ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才启用分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作会保持不可用。不可用的控件仍然可见、可聚焦、可悬停;`aria-disabled`、tooltip 与 `aria-describedby` 会说明已完成尾部这一要求,且不会发送 Host 请求。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 +本资格判定中消息气泡的那一半已被 [user 气泡分支移除决策](../simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)取代:user 与 steering 气泡不再渲染该控件,因此只有内容 assistant 尾部可以 fork;assistant 侧门禁及其可见但不可用的呈现保持有效。 + 本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index 3c7f8f4992..45c03a2347 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: 3b97089cdffe006bbb401c4cf61c1379da7f8828 -2026-07-29-web-message-icon-actions-and-clock.zh.md: abb6e200ccea4a227e5db3ac48f0410cb3349526 +2026-07-29-web-message-icon-actions-and-clock.md: feced6aeb11d176d6c774242a4d1dae14f6730f8 +2026-07-29-web-message-icon-actions-and-clock.zh.md: 5e33182421b423f45c84dbe1a979505f4c31b819 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index 3b97089cdf..feced6aeb1 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -12,7 +12,7 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo **User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** -The assistant seat is narrowed by the [completed-turn decision](../bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md): only a turn with a `turn/end` grants it, so a turn still producing steps hands the row to nothing. +The assistant seat is narrowed by the [completed-turn decision](../bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md): only a turn with a `turn/end` grants it, so a turn still producing steps hands the row to nothing. The user seat's branch control is removed outright by the [user-bubble branch removal](../simplification/2026-08-06-user-bubbles-drop-the-branch-action.md); a user row's IconActions are clock and copy. Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds `time` for mid-turn content; `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, and the streaming tail omit the row. Copy writes joined text blocks. Both message rows pass their event's `seq` to the same fork callback; [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the real mutation contract. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index abb6e200cc..5e33182421 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -12,7 +12,7 @@ Web 聊天的用户气泡已有复制、分支、编辑 IconActions,但没有 **用户气泡在既有 IconActions 行的开头添加感知日期的本地时钟;每个轮次中最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制、分支、时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** -assistant 一侧的座位由[已完成轮次决策](../bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md)收紧:只有存在 `turn/end` 的轮次才授予该行,仍在产出步骤的轮次不把该行交给任何节点。 +assistant 一侧的座位由[已完成轮次决策](../bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md)收紧:只有存在 `turn/end` 的轮次才授予该行,仍在产出步骤的轮次不把该行交给任何节点。user 一侧的分支控件被 [user 气泡分支移除决策](../simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)直接移除;user 行的 IconActions 只有时钟与复制。 两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架钩子。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导轮次尾部的 seq,并不为轮次中间的内容传入 `time`;`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、轮次中间的叙述与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 `seq` 交给同一个 fork 回调;真实 mutation 契约由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装后的界面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 diff --git a/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.i18n.yaml new file mode 100644 index 0000000000..36404ebcdd --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.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/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md +2026-08-06-user-bubbles-drop-the-branch-action.md: 817b5e72b7e18b03ddb3a160e6f7a86b04f02764 +2026-08-06-user-bubbles-drop-the-branch-action.zh.md: dab3890818d872d7bbb3ac9bcab014ce9b829a61 diff --git a/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md b/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md new file mode 100644 index 0000000000..817b5e72b7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md @@ -0,0 +1,27 @@ +# Agent Note: User and steering bubbles drop the branch action + +Status: implemented + +English | [中文](2026-08-06-user-bubbles-drop-the-branch-action.zh.md) + +## Problem + +Every user and consumed-steering bubble rendered the branch control under the completed-turn-tail gate of the [completed-turn-tail decision](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md). On those bubbles the gate is effectively permanent: a turn-opening user message is followed by its own turn's nodes, and a consumed steering message is mid-turn by construction, so the control could enable only when the turn ended with no node after the message at all — a cancel before the first model event. Readers therefore saw a control that never enables, with a tooltip promising a state the button cannot reach. The affordance also misled when read at all: a fork at a message seq cuts at the containing `turn/end`, so "branch at my message" includes the answer below it — the opposite of the branch-to-re-ask reading a control on one's own bubble suggests. + +## Decision + +User and steering bubbles render no branch action. `MessageItem` loses its fork props, `PendingSteeringBubble` loses its `showBranch` special case, and `messageBranchSeqs` narrows to `assistantBranchSeqs`: only a completed turn's transcript tail that is the turn's own content-text assistant may fork. The branch affordance lives solely under the settled answer. + +A turn containing a steer keeps its fork point unchanged: fork is a log-prefix cut at `turn/end`, and the steer is model-visible history the child must inherit, so the settled answer of a steered turn forks like any other. The assistant-side gate and its visible-but-unavailable presentation are also unchanged — under an answer, unavailable is a transient, reachable state (a trailing tool or error row currently owns the tail), which is exactly what the tooltip is for. + +## Alternatives considered + +**Hide the control on message bubbles only while ineligible.** Rejected: it preserves the near-unreachable enabled case at the cost of an icon that appears on one's own bubble only when a turn died before producing anything, an inconsistency not worth the case it serves. + +**Keep the visible-but-unavailable control (status quo).** Rejected: the [completed-turn-tail decision](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md) chose visibility so the tooltip could explain a boundary the reader can reach; on user and steering bubbles the boundary is unreachable in practice, so the explanation props up a control that should not exist there. + +**Branch-before-the-message semantics on user bubbles.** Out of scope: re-asking from one's own prompt needs a cut before the message plus composer prefill, a different Host operation. Removing the current control keeps that seat free for such a feature instead of squatting on it with opposite semantics. + +## Consequences + +The only fork handles are the enabled branch controls under settled answers. A turn cancelled before any node followed its message loses its only handle and has no fork point, matching turns whose tail is a content-free interrupted node. Web aria goldens across `apps/web` drop the user-bubble disabled-branch row and its hidden explanation text. Package tests pin that user and steering bubbles render no branch control and that a steering-tail turn leaves the narration's control unavailable. diff --git a/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md b/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md new file mode 100644 index 0000000000..dab3890818 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md @@ -0,0 +1,27 @@ +# Agent Note:user 与 steering 气泡移除分支操作 + +Status: implemented + +[English](2026-08-06-user-bubbles-drop-the-branch-action.md) | 中文 + +## 问题 + +每个 user 气泡和已消费的 steering(中途引导)气泡都渲染分支控件,受[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)的门禁约束。在这些气泡上,该门禁实际上是永久性的:开轮的 user 消息后面必然跟着本轮自己的节点,已消费的 steering 消息按构造就处在轮次中间,因此只有当轮次结束时该消息之后一个节点都没有——即在第一个模型事件之前就取消——控件才可能启用。读者因此看到一个永远不会启用的控件,tooltip 许诺的是这个按钮到达不了的状态。这个操作入口本身也有误导:在消息 seq 处 fork 会切在所在轮次的 `turn/end`,"在我的消息处分支"实际会把下方的回答一并带走,与在自己气泡上看到分支时"分叉重问"的直觉预期恰好相反。 + +## 决策 + +user 与 steering 气泡不再渲染分支操作。`MessageItem` 移除其 fork props,`PendingSteeringBubble` 移除其 `showBranch` 特例,`messageBranchSeqs` 收窄为 `assistantBranchSeqs`:只有已完成轮次的 transcript 尾部、且该尾部是本轮自己的带 text 内容 assistant 节点才可 fork。分支入口只存在于已定稿的回答之下。 + +含有 steer 的轮次的 fork 点保持不变:fork 是切在 `turn/end` 上的日志前缀,steer 是子会话必须继承的模型可见历史,因此被引导过的轮次的已定稿回答与其他轮次一样可以 fork。assistant 侧的门禁及其可见但不可用的呈现也保持不变——在回答之下,不可用是一个短暂且可到达的状态(当前尾部被后续工具行或错误行占据),这正是 tooltip 的用武之地。 + +## 考虑过的替代方案 + +**仅在不可用时隐藏消息气泡上的控件。** 否决:它保住了那个几乎不可达的启用场景,代价是图标只在轮次尚未产出任何东西就中止时才出现在自己的气泡上,这种不一致不值得为它服务的场景付出。 + +**保留可见但不可用的控件(现状)。** 否决:[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)选择可见,是为了让 tooltip 解释一个读者可以到达的边界;在 user 与 steering 气泡上这个边界实际不可达,解释文本是在为一个不该存在于此的控件打补丁。 + +**在 user 气泡上采用切在消息之前的分支语义。** 不在本次范围内:从自己的提示词重问需要切在消息之前并预填输入框,是另一个 Host 操作。移除当前控件恰好为这样的功能留出位置,而不是让语义相反的控件占着它。 + +## 后果 + +唯一的 fork 入口是已定稿回答下方启用的分支控件。在任何节点跟上其消息之前就被取消的轮次失去了它唯一的入口,从此没有 fork 点,与尾部是无内容 interrupted 节点的轮次一致。`apps/web` 的 aria golden 全部移除 user 气泡的禁用分支行及其隐藏说明文本。包测试钉住:user 与 steering 气泡不渲染分支控件,steering 作为尾部的轮次让叙述节点的控件保持不可用。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index aac2c4806c..6149a66df1 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -107,17 +107,17 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). Every durable message footer keeps branch visible, - // but only the final assistant at a completed transcript tail enables it. + // hover/focus-within). Branch renders only under assistant answers — user + // bubbles carry none — and only a completed transcript tail enables it. const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) - await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4) + await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(2) await expect.poll( () => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))), { timeout: 5_000 }, - ).toEqual(['true', 'true', 'true', null]) + ).toEqual(['true', null]) await branchButtons.first().focus() await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 }) .toBe('Available only on the last message of a completed turn') 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 1b9e6aa339..d626830553 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -7,9 +7,6 @@ - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}" - button "Copy": - img -- 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": - img - img 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 0c2cf8604c..99b6bac89b 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -7,9 +7,6 @@ - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img -- 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": - 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 33b1d6cd0f..72d0a79756 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -7,9 +7,6 @@ - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "Copy": - img -- 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": - 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 aebc2a45b6..92183ee6ea 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,9 +7,6 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- 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": - 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 6b6671ec01..bf32465f2b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -7,9 +7,6 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- 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": - 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 9735b8acfe..01a8343313 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -7,9 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- 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": - img - img 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 be1d936dd2..f75432e2e4 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -7,9 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- 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": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index 6e81c87205..6c36405064 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -7,9 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- 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": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index f127d3e8d1..a281ca26b2 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -7,9 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- 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": - img - img diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index b28e30e4ef..5a182175ee 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -7,9 +7,6 @@ - text: Render adjacent CJK strong emphasis. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - heading "CJK strong emphasis" [level=2] - paragraph: - strong: 注意: diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 0f9c471a65..b7d39d5ac0 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -7,9 +7,6 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 71851363d2..cc255cf0b0 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -7,9 +7,6 @@ - text: Show the local preview URL. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - heading "Inline code links" [level=2] - paragraph: - text: "Preview:" diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md index be1bbb7069..18bc3b791f 100644 --- a/apps/web/tests/snapshots/math-rendering/ui.expected.md +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -7,9 +7,6 @@ - text: Render this mathematical proof. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - heading "Math rendering" [level=2] - paragraph: - text: Inline dollar diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 81c2796e5a..0adabf54d8 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -8,9 +8,6 @@ - button "Copy": - img - tooltip "Copy" -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img @@ -38,9 +35,6 @@ - text: Stopped Now give the final answer. 7/25 {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - paragraph: DONE - button "Copy": - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index f0c7d718e0..c1cae54bb5 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -8,9 +8,6 @@ - text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- 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": - 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 82e0b468c1..a524a02e23 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -7,9 +7,6 @@ - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- 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": - 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 cdde5d8790..18b40d976a 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -7,9 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- 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": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 8bfd2f964d..7dc4f38f86 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -7,9 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- 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": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index e8b65fdea1..e1b1cf9084 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -7,9 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- 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": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 48b714a88c..0d9ae5fcf3 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -7,9 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- 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": - img - img 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 467a4364b8..6e8d1eb0f7 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -7,9 +7,6 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 55fcb89ec8..d0ce89bc90 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -7,9 +7,6 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index c32cee0077..5f3f24f709 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -7,9 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- 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": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 77385c6333..d598613fa3 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -7,9 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- 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": - img - img @@ -25,9 +22,6 @@ - text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md index 9f7c0f23f7..da57314953 100644 --- a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md @@ -11,8 +11,6 @@ - text: Give one concrete event sourcing example. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - status: - strong: This subagent is read-only for now - text: The parent session is offline; reopen it to continue sending messages. diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index a01eea56d8..27c7ec092e 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -12,9 +12,6 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- 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": - img - img @@ -31,9 +28,6 @@ - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Now give the same explanation to a human reader. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - 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/turn-tail-actions/running.expected.md b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md index 7780798b41..0dd1189e3c 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md @@ -8,9 +8,6 @@ - button "Copy": - img - tooltip "Copy" -- 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": - img - img diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md index 082aecaf9b..828350b846 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -7,9 +7,6 @@ - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy": - img -- 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": - 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 1e2dcf9eca..0281d242f4 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -7,9 +7,6 @@ - text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img -- 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": - img - img diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 11e22d29d4..235145ebcc 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -122,10 +122,11 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), { timeout: 10_000 }, ).toBe(true) - // Only the user bubble owns a footer: the narration is not the answer yet. + // Only the user bubble owns a footer (clock + copy; user bubbles carry no + // branch action): the narration is not the answer yet. const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(1) - expect(await page.getByRole('button', { name: 'Branch into a new conversation' }).count()).toBe(1) + expect(await page.getByRole('button', { name: 'Branch into a new conversation' }).count()).toBe(0) await copyButtons.first().focus() const running = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 50a28ac676..1c8f2be8df 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: c01be00a82a23feeaae18bd55668163803de9ef7 -README.zh.md: c5102576e4e030f0662135baa6c9a3d30e1ad846 +README.md: 3b4629cf1ec1bb5f136f80228a82b8aae3f4dd45 +README.zh.md: 02680e9f7d8ad71a6c93c886b6982b6b5ab81a43 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c01be00a82..3b4629cf1e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ The todo surfaces are two registrations over that shape, both using slot declara `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. @@ -64,7 +64,7 @@ None; this package neither assembles nor sends a provider request. - **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. - **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. - **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 that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps 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)). +- **Sent user messages cannot be edited** — user bubbles retain clock and copy; branch lives only under assistant answers ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)). 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)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel has no durable grant control** — it supports allow-once and reject only. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c5102576e4..02680e9f7d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 @@ -64,7 +64,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu - **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 - **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))。 +- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;分支只存在于 assistant 回答之下([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index e902a5c75d..6058efca98 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -362,7 +362,7 @@ export function ChatView({ // mid-turn text and every node of a running turn omit `time`, so // AssistantMarkdown stays chrome-free until the answer settles. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds]) - const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) + const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) @@ -632,8 +632,6 @@ export function ChatView({ <MessageItem node={node} retryActive={node.kind === 'model-retry' && node.seq === activeRetry} - onFork={forkAt} - forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 99aca83dde..d70912e346 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -27,8 +27,6 @@ export interface MessageIconActionsProps { onBranch?: (() => void) | undefined /** The message is not a completed transcript tail, so branch stays visible but unavailable. */ branchUnavailable?: boolean | undefined - /** Additional branch visibility gate for transient message chrome; defaults to true. */ - showBranch?: boolean | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -41,7 +39,7 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t, + text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() const reasonId = useId() @@ -111,7 +109,7 @@ export function MessageIconActions({ {copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />} </button> </Tooltip> - {showBranch && onBranch !== undefined && ( + {onBranch !== undefined && ( <Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom"> {/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */} <button @@ -127,7 +125,7 @@ export function MessageIconActions({ </button> </Tooltip> )} - {showBranch && onBranch !== undefined && branchUnavailable && ( + {onBranch !== undefined && branchUnavailable && ( <span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span> )} {clock === 'end' ? clockEl : null} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 994cd9ca5c..5473c9f8a2 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,8 +1,8 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (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. +// (right-aligned, with clock + copy IconActions; steering adds the +// interjection caption that names it; branch lives only under assistant +// answers), 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' @@ -27,10 +27,6 @@ export interface MessageItemProps { | TurnErrorNode | UnknownSurfaceNode retryActive?: boolean - /** Fork through this message's completed turn when eligible. */ - onFork?: (seq: number) => void - /** The message is not the transcript tail of a completed turn. */ - forkUnavailable?: boolean /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -217,7 +213,6 @@ export function PendingSteeringBubble({ content, t }: { <MessageIconActions text={text} clock="start" - showBranch={false} className={css.actions} t={t} /> @@ -227,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: { } export const MessageItem = memo(function MessageItem({ - node, retryActive = false, onFork, forkUnavailable = false, t, + node, retryActive = false, t, }: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { @@ -243,8 +238,6 @@ export const MessageItem = memo(function MessageItem({ text={text} time={node.time} clock="start" - onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} - branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 31523ae365..e3b8e5ba2c 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -75,15 +75,18 @@ export function runningTurnStartTime( } /** - * Seq set of message rows that may fork: the last transcript node of a - * completed turn, when that node owns message chrome. A later tool, reasoning, - * error, or other transcript node leaves the earlier message's branch action - * unavailable because the Host would include the whole turn. + * Seq set of assistant answers that may fork: the completed turn's transcript + * tail, when that tail is the turn's own content-text assistant. A later tool, + * reasoning, error, or other transcript node leaves the answer's branch action + * unavailable because the Host would include the whole turn. User and steering + * bubbles carry no branch action at all: a fork at their seq cuts at the same + * `turn/end` as the answer's, so the affordance lives only under the settled + * answer. * @param nodes - snapshot nodes in event order. * @param turnEnds - completed turn boundaries retained from the event window. - * @returns Message seq values whose visible position matches the fork boundary. + * @returns Assistant seq values whose visible position matches the fork boundary. */ -export function messageBranchSeqs( +export function assistantBranchSeqs( nodes: readonly ConversationNode[], turnEnds: ReadonlyMap<number, number>, ): ReadonlySet<number> { @@ -98,8 +101,7 @@ export function messageBranchSeqs( tail = candidate nodeIndex++ } - if (tail?.kind === 'user' || tail?.kind === 'steering' - || (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) { + if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) { result.add(tail.seq) } } 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 53793f86c2..3122b0fdc7 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -36,7 +36,7 @@ afterEach(() => { const t: MessageItemProps['t'] = makeTranslate(zh, commonZh) describe('MessageItem arms', () => { - it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => { + it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -45,24 +45,20 @@ describe('MessageItem arms', () => { // Same-day clock: construct "today at 14:24" so the label stays `HH:mm`. const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() - const onFork = vi.fn() render( <MessageItem t={t} node={{ kind: 'user', seq: 1, time, content: [{ type: 'text', text: 'hello bubble' }] as never, source: null, }} - onFork={onFork} />, ) expect(screen.getByText('14:24')).toBeTruthy() expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() - expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull() expect(screen.queryByRole('button', { name: '编辑' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('hello bubble') - fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' })) - expect(onFork).toHaveBeenCalledWith(1) }) it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => { @@ -87,30 +83,6 @@ describe('MessageItem arms', () => { expect(exec).toHaveBeenCalledWith('copy') }) - it('keeps an unavailable branch focusable and explains why without sending a fork', () => { - const onFork = vi.fn() - render( - <MessageItem t={t} node={{ - kind: 'user', seq: 1, time: 1_000, - content: [{ type: 'text', text: 'open turn' }] as never, - source: null, - }} - onFork={onFork} - forkUnavailable - />, - ) - const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement - expect(branch.disabled).toBe(false) - expect(branch.getAttribute('aria-disabled')).toBe('true') - const reasonId = branch.getAttribute('aria-describedby') - expect(reasonId).not.toBeNull() - expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支') - fireEvent.click(branch) - expect(onFork).not.toHaveBeenCalled() - fireEvent.focus(branch) - expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') - }) - it('user copy never claims success when the host rejects the write', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -212,19 +184,17 @@ describe('MessageItem arms', () => { expect(vi.getTimerCount()).toBe(0) }) - it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => { + it('consumed steering is captioned as an interjection and keeps copy without branch', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) - const fork = vi.fn() const view = render( <MessageItem t={t} node={{ kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null, content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never, } as never} - onFork={fork} />, ) expect(view.getByText('插话')).toBeTruthy() @@ -232,8 +202,7 @@ describe('MessageItem arms', () => { expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('steer!') - fireEvent.click(view.getByRole('button', { name: '在新对话中分支' })) - expect(fork).toHaveBeenCalledWith(2) + expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() }) it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => { @@ -1002,6 +971,31 @@ describe('small branch tails', () => { expect(streaming.queryByText('14:24')).toBeNull() }) + it('keeps an unavailable branch focusable and explains why without sending a fork', () => { + const onFork = vi.fn() + render( + <AssistantMarkdown + t={t} + blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]} + streaming={false} + time={1_000} + seq={1} + onFork={onFork} + forkUnavailable + />, + ) + const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement + expect(branch.disabled).toBe(false) + expect(branch.getAttribute('aria-disabled')).toBe('true') + const reasonId = branch.getAttribute('aria-describedby') + expect(reasonId).not.toBeNull() + expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支') + fireEvent.click(branch) + expect(onFork).not.toHaveBeenCalled() + fireEvent.focus(branch) + expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') + }) + it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => { // Cache hit is null only when all three prompt buckets are zero (pure // output accounting) — any billed input makes it a real 0%. diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 20daca0d7f..2369e8f1d6 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' afterEach(() => { @@ -264,7 +264,7 @@ describe('chat-flow derivation', () => { expect(formatRunDuration(125_000, t)).toBe('2分05秒') }) - it('messageBranchSeqs keeps only message rows at completed transcript tails', () => { + it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => { const interruptedThink: AssistantMessageNode = { kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, @@ -279,8 +279,8 @@ describe('chat-flow derivation', () => { user(10, 'user-only tail'), user(13, 'steering tail'), ] - const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]])) - expect([...seqs]).toEqual([7, 10, 13]) + const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]])) + expect([...seqs]).toEqual([7]) }) }) @@ -405,22 +405,23 @@ describe('ChatView', () => { expect(view.container.querySelector('[data-pending-steering]')).toBeNull() expect(view.getAllByText('插话')).toHaveLength(1) // Only the durable steering bubble: the turn is still running, so its - // assistant narration owns no footer yet. + // assistant narration owns no footer yet, and a steering bubble never + // carries a branch action. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1) const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement - const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' }) - expect(unavailable.getAttribute('aria-disabled')).toBe('true') - fireEvent.click(unavailable) - expect(h.forkAt).not.toHaveBeenCalled() + expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull() act(() => { h.set({ running: false, turnEnds: new Map([[1, 3]]) }) }) + // The completed turn's transcript tail is the steering bubble, not the + // narration, so the assistant's branch action stays unavailable and the + // steering bubble still offers none. const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(branchButtons).toHaveLength(2) - expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null]) - fireEvent.click(branchButtons[1]!) - expect(h.forkAt).toHaveBeenCalledWith(2) + expect(branchButtons).toHaveLength(1) + expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(branchButtons[0]!) + expect(h.forkAt).not.toHaveBeenCalled() }) it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => { @@ -523,11 +524,11 @@ describe('ChatView', () => { turnEnds: new Map([[1, 4], [2, 6]]), }) const view = render(<h.ChatView {...h.props} />) - // Every message footer keeps branch visible; only completed assistant tails enable it. + // Branch renders only under assistant answers; user bubbles keep copy alone. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(branchButtons).toHaveLength(4) - expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null]) + expect(branchButtons).toHaveLength(2) + expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null]) }) it('withholds assistant IconActions while the turn is still running', () => { @@ -635,11 +636,11 @@ describe('ChatView', () => { turnEnds: new Map([[1, 3]]), }) const view = render(<h.ChatView {...h.props} />) + // The user bubble offers no branch; the settled answer's is live. const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(buttons).toHaveLength(2) - expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null]) + expect(buttons).toHaveLength(1) + expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull() fireEvent.click(buttons[0]!) - fireEvent.click(buttons[1]!) expect(h.forkAt.mock.calls).toEqual([[2]]) }) @@ -655,10 +656,9 @@ describe('ChatView', () => { const view = render(<h.ChatView {...h.props} />) expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(buttons).toHaveLength(2) - expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true) + expect(buttons).toHaveLength(1) + expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true') fireEvent.click(buttons[0]!) - fireEvent.click(buttons[1]!) expect(h.forkAt).not.toHaveBeenCalled() }) From 318142ebe9aaef1bcf27c3949f8f59b1557c21e4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 19:21:04 +0800 Subject: [PATCH 139/190] docs(user): add a model-provider configuration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide tier said how to compose plugins with `cordis.yml` but never how to reach a provider other than DeepSeek, so the two things a person actually does — give a catalog provider its key from the Models page, and declare a gateway the installed catalog does not ship — had no home outside package READMEs. The new page covers both entry points and the relationship between them: the Models page and `$DSH_HOME/settings.yaml` write one document, over a `llm-pi-ai` adapter that mounts dormant until that document names routes. It carries the settings shape, catalog replacement and its capacity fallbacks, credential references, and the four failures a misconfigured route produces, and links the generated config catalog for exhaustive fields. It sits between Quick start and Configuration in the guide sidebar, which is where a reader hits the question. --- docs/user/guide/providers.i18n.yaml | 6 ++ docs/user/guide/providers.md | 113 +++++++++++++++++++++++++++ docs/user/guide/providers.zh.md | 113 +++++++++++++++++++++++++++ docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 1 + docs/user/guide/quickstart.zh.md | 1 + website/docs.ts | 10 ++- 7 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 docs/user/guide/providers.i18n.yaml create mode 100644 docs/user/guide/providers.md create mode 100644 docs/user/guide/providers.zh.md diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml new file mode 100644 index 0000000000..04b27adb51 --- /dev/null +++ b/docs/user/guide/providers.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 docs/user/guide/providers.md +providers.md: 2ae1093699d8eb26171a2403db155113d84e437e +providers.zh.md: 6ce513c659140ed18716bd5c8f75c428ad981f2b diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md new file mode 100644 index 0000000000..2ae1093699 --- /dev/null +++ b/docs/user/guide/providers.md @@ -0,0 +1,113 @@ +# Configure model providers + +English | [中文](providers.zh.md) + +Harness ships with DeepSeek and mounts a generic multi-provider adapter alongside it, for the providers in pi-ai's installed catalog — Anthropic, OpenAI, and the rest — and for any OpenAI-compatible gateway or self-hosted server. You have two entry points: the **Models** page in the web UI, and `$DSH_HOME/settings.yaml`. Both write the same document, and a change takes effect on the next request without a restart. + +## Where providers come from + +`cordis.yml` decides which **adapters** are installed; the settings document decides which **providers** run. The shipped composition carries two LLM adapters: + +- `llm-deepseek` serves the `deepseek-official` route, the one available out of the box. +- `llm-pi-ai` mounts **dormant**: zero routes and no extra entries in the model picker until an `llm-pi-ai:` settings section supplies provider profiles, at which point those routes register live and drop again when the section empties. + +Adding a provider therefore rarely means editing `cordis.yml` — writing settings is enough, and that is exactly what the Models page does. + +## Configure from the web UI + +Start `pnpm run dsh web` and open **Settings → Models**. + +**Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready. + +**Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. + +**Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. + +**Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. + +Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. + +## settings.yaml for advanced configuration + +The document lives at `$DSH_HOME/settings.yaml` (`$DSH_HOME` defaults to `~/.dsh`). The Models page writes this file, and you can edit it directly; neither source outranks the other. + +```yaml +llm-deepseek: + reasoningEffort: high + +llm-pi-ai: + providers: + # Catalog route: endpoint, protocol, and models come from pi-ai; you supply + # the credential. + openai: + apiKeyEnv: OPENAI_API_KEY + + # Also a catalog route, moved to a private proxy, with its catalog narrowed + # to one model and that model's capacity corrected. Every unset field still + # comes from the catalog. + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY + baseURL: https://proxy.example.com:8443 + reasoning: high + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 +``` + +A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them. + +A profile the adapter could not serve is refused **where it is written**: a hand-declared route needs `api`, `baseURL`, and at least one model, and a profile missing any of them fails naming the offending route and model rather than being stored and quietly disabling the whole namespace. When an already-stored document is broken by an external edit, settings keeps the last good value and warns. + +## The model catalog + +A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit. + +Only the four fields the harness consumes are configurable: `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no consumer, and reasoning is not per-model configurable at all — it rides the installed catalog entry. + +A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once. + +Model ids are not lifecycle configuration. Requesting a model the route does not configure fails with `UNKNOWN_MODEL` before any provider request goes out. + +## Credentials + +Prefer `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. A literal `apiKey` is the escape hatch. Omitting both is what leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. + +References resolve from `$DSH_HOME/.env` — what the Models page's key fields write — and from the matching environment variable when no credential service is mounted. One credential serves every model on its route. + +## Point an agent at the new provider + +A configured route appears in the web model picker and can be switched at any time. To change the default, edit the `agent-loop` entry's `provider` and `model` in `cordis.yml`: + +```yaml +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: + - id: main + provider: acme-gateway + model: acme-large +``` + +## Troubleshooting + +- **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. +- **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries. +- **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present. +- **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead. + +## Exact field reference + +The complete fields, types, and defaults each plugin currently supports live in the generated [plugin configuration catalog](../../config-catalog.md). Each adapter's own semantics belong to its README: [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md). For `cordis.yml` itself, see [Configuration](./config.md). diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md new file mode 100644 index 0000000000..6ce513c659 --- /dev/null +++ b/docs/user/guide/providers.zh.md @@ -0,0 +1,113 @@ +# 配置模型提供方 + +[English](providers.md) | 中文 + +Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器,用来接入 Anthropic、OpenAI 这类内置目录里的提供方,或任何 OpenAI 兼容的网关与自建服务。你有两个入口:Web 界面的**模型**页,以及 `$DSH_HOME/settings.yaml`。两者写的是同一份文档,改完下一次请求即生效,不用重启。 + +## 提供方从哪里来 + +`cordis.yml` 决定装了哪些**适配器**,settings 文档决定跑哪些**提供方**。出厂组合里有两个 LLM 适配器: + +- `llm-deepseek` 提供 `deepseek-official` 路由,是默认可用的那个。 +- `llm-pi-ai` 以**休眠**状态挂载:零路由,模型选择器里也不会多出条目,直到 settings 里的 `llm-pi-ai:` 段落给出 provider profile,路由才注册上来;段落清空则一并撤下。 + +因此新增一个提供方通常不需要改 `cordis.yml`,写 settings 就够了——而模型页做的正是这件事。 + +## 在 Web 界面里配置 + +启动 `pnpm run dsh web`,打开**设置 → 模型**。 + +**填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。 + +**添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 + +**添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 + +**让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 + +密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 + +## settings.yaml:进阶配置 + +文档位于 `$DSH_HOME/settings.yaml`(`$DSH_HOME` 默认是 `~/.dsh`)。模型页写的就是这个文件,你也可以直接编辑它——两个来源没有主次之分。 + +```yaml +llm-deepseek: + reasoningEffort: high + +llm-pi-ai: + providers: + # Catalog route: endpoint, protocol, and models come from pi-ai; you supply + # the credential. + openai: + apiKeyEnv: OPENAI_API_KEY + + # Also a catalog route, moved to a private proxy, with its catalog narrowed + # to one model and that model's capacity corrected. Every unset field still + # comes from the catalog. + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY + baseURL: https://proxy.example.com:8443 + reasoning: high + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 +``` + +settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。 + +一份服务不了的 profile 会在**写入处**被拒绝:手工声明的路由必须给出 `api`、`baseURL` 和至少一个模型,缺了会带着路由名和模型名报错,而不是存下来再让整个命名空间静默失效。已经存好的文档被外部改坏时,settings 会保留上一次的好值并告警。 + +## 模型目录 + +`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑。 + +可配置的只有 harness 会消费的四个字段:`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态没有消费方,推理能力也不按模型配置——它随内置目录条目走。 + +两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`(262144)与 `defaultMaxTokens`(32768)。这两个数按定义就是猜测,所以它们是路由字段:网关服务的模型更小时改一次即可。 + +模型 id 不是生命周期配置:请求一个该路由没有配置的模型,会在任何网络请求之前以 `UNKNOWN_MODEL` 失败。 + +## 凭据 + +优先用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件;`apiKey` 字面量是应急出口。两者都不给,才表示这个路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 + +引用解析自 `$DSH_HOME/.env`(模型页的密钥输入框写的就是它),没有挂载凭据服务时则直接读同名环境变量。一份凭据供该路由上的所有模型使用。 + +## 让 agent 用上新提供方 + +配好的路由会出现在 Web 的模型选择器里,随时可切。要改默认值,就在 `cordis.yml` 里改 `agent-loop` 那条的 `provider` 与 `model`: + +```yaml +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: + - id: main + provider: acme-gateway + model: acme-large +``` + +## 排错 + +- **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 +- **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。 +- **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api`、`baseURL`、`models` 是否齐全。 +- **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。 + +## 精确字段参考 + +每个插件当前支持的完整字段、类型与默认值见自动生成的[插件配置目录](../../config-catalog.md)。两个适配器各自的语义由它们的 README 负责:[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 与 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md)。`cordis.yml` 本身的写法见[配置文件](./config.md)。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 2cf494f71a..74fd06f83d 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/user/guide/quickstart.md -quickstart.md: 199b3f092159fa6fbaf3ae298151487c924ac6f1 -quickstart.zh.md: 9327ed646ba211bcce6426beb6bf76fca50acbf6 +quickstart.md: 8a9ed716d9395448aadfb97d0935bd42ee06e6c1 +quickstart.zh.md: 3652b0453f870640b278ce6f1355e67e85983ffe diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 199b3f0921..8a9ed716d9 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,5 +57,6 @@ headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead compo ## Next steps +- [Model providers](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 9327ed646b..3652b0453f 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,5 +57,6 @@ headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则组合 [`ap ## 下一步 +- [配置模型提供方](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的 tool 或后端 diff --git a/website/docs.ts b/website/docs.ts index 8d42ae3209..2b9c4654c4 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -130,13 +130,21 @@ const homeAndGuide = pairedPages([ section: { root: '入门', en: 'Guide' }, order: 2, }, + { + source: 'docs/user/guide/providers.md', + route: 'guide/providers.md', + label: { root: '配置模型提供方', en: 'Model providers' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, + order: 3, + }, { source: 'docs/user/guide/config.md', route: 'guide/config.md', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 3, + order: 4, }, ]) From 87d0fc6fc42d1fe1afa244b40e1bd4faead658f0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:31:34 -0700 Subject: [PATCH 140/190] cleanup: drop leftovers from the retired HTTP-serving revision --- packages/client/connection/src/client/fixture.ts | 1 - packages/client/connection/src/index.ts | 3 +-- .../client/connection/tests/client-apply.spec.ts | 1 - packages/client/connection/tests/node-half.spec.ts | 13 +++++-------- .../client/runtime/src/client/workspaces/service.ts | 1 - packages/client/test-runtime/src/workspaces.ts | 1 - .../src/client/chat/Deliverables.tsx | 3 +-- 7 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0549fc1160..9f091b26c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2362,7 +2362,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) return Promise.resolve({ accepted: true }) }, - } } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 03f8aaa257..ed4af2d21f 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -13,7 +13,7 @@ export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before mounting the routes. */ +/** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -96,5 +96,4 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - } diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index f9fe1c1b71..6892dc7721 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -62,5 +62,4 @@ describe('connection client apply', () => { } expect(seen.some(u => u.includes('/api/'))).toBe(true) }) - }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 216484ad67..08c65de2ba 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -30,15 +30,11 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session } /** Response recorder compatible with both the fence's short-circuit and the bridge. */ -function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown; headers?: Record<string, string> } } { - const state: { status?: number; body?: unknown; headers?: Record<string, string> } = {} +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { + const state: { status?: number; body?: unknown } = {} const response = Object.assign(new EventEmitter(), { writableEnded: false, - writeHead(value: number, headers?: Record<string, string>) { - state.status = value - if (headers !== undefined) state.headers = headers - return this - }, + writeHead(value: number) { state.status = value; return this }, write() { return true }, end(this: { writableEnded: boolean }, value?: unknown) { if (value !== undefined) state.body = value @@ -72,7 +68,8 @@ describe('connection node half', () => { it('registers the /api prefix route and removes it with the fiber', async () => { const { routes, dispose } = await mounted() - expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }]) + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) await dispose() expect(routes).toHaveLength(0) }) diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index c0eb46fcf9..a0a76670f2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -239,7 +239,6 @@ export class WorkspacesService implements IWorkspaces { } } - /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 95f6574405..7e626a3660 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -98,7 +98,6 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined) } - /** * Directory picker (recorded). The default cancels (null); stub to select. * @returns the picked path, or null. diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx index 0a0160b486..7d62e23401 100644 --- a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx @@ -2,8 +2,7 @@ // from the mutation tools' follow-along locations (see turnDeliverables), never // from the closing prose, so the answer carries its own output whether or not // the model remembered to name it. Clicking one goes through the same openFile -// the tool rows use — in the browser that is a new tab served from the session -// workspace, and outside it the Host's own opener. +// the tool rows use — the Host's own opener, on the Host machine. import type { ChatViewSlotProps } from '../contract/slots.ts' import css from './Deliverables.module.css' From a231b56eba22681e0015b6339f2116381584308b Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 19:58:31 +0800 Subject: [PATCH 141/190] docs(user): show the Models page in the provider guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page told a reader to open Settings → Models and named the two buttons, which is thin help for someone who has never seen the page. Two screenshots per language carry it instead: the Models page with its provider card and both add actions, and the custom-provider form with the fields it asks for. They are the first images under docs/. The projector rewrites a repository-relative image to a raw.githubusercontent URL pinned at the built commit, so nothing is copied into the site bundle, and the pairing gate takes no signature from image nodes — which is what lets each language carry its own localized capture. --- docs/user/guide/providers-custom-form.png | Bin 0 -> 58692 bytes docs/user/guide/providers-custom-form.zh.png | Bin 0 -> 57720 bytes docs/user/guide/providers-models-page.png | Bin 0 -> 75818 bytes docs/user/guide/providers-models-page.zh.png | Bin 0 -> 70021 bytes docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 4 ++++ docs/user/guide/providers.zh.md | 4 ++++ 7 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/user/guide/providers-custom-form.png create mode 100644 docs/user/guide/providers-custom-form.zh.png create mode 100644 docs/user/guide/providers-models-page.png create mode 100644 docs/user/guide/providers-models-page.zh.png diff --git a/docs/user/guide/providers-custom-form.png b/docs/user/guide/providers-custom-form.png new file mode 100644 index 0000000000000000000000000000000000000000..bbbedde794dcf1f5b55fc7b9418ff7bca3e0cfde GIT binary patch literal 58692 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�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 VNAUC%3!fJ?5@YXdS7&o3`JaP0h|&N6 literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers-custom-form.zh.png b/docs/user/guide/providers-custom-form.zh.png new file mode 100644 index 0000000000000000000000000000000000000000..2c4812033145261e312c3e79348c38a4cd3d514b GIT binary patch literal 57720 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 LVLc;}}Q+v*3hLcuR#J#;$0B(|`}t&U&%c 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�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�)jwp);+2!aY8P>`UA<b23EC?XU{0afIv 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 V2}{D{Yioca44$rjF6*2UngBln29f{( literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers-models-page.png b/docs/user/guide/providers-models-page.png new file mode 100644 index 0000000000000000000000000000000000000000..f3ffe6e90f6768c31a6816ff7728488eff70a893 GIT binary patch literal 75818 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∋kykQAux=i$QD!{Ip(+QK7)zAU+=05`fVV6j53<bY5&)% ziyCiFCwH~h0mi|B8-RsqlBri@nF7RU3W>i+4FF<9qsFs!A5_&Z?r0On?*O9#z4kI= zj|m+>X-Dbhx;dRkW98vESo$`R&#E5(@d|->g%%{!)^G3{rX7FAcLA=49jo&G`6y^; z{+KQI%)n^D^-y0O$Q=>!tAJXXe<8wZmD%`?74|eEh=}X+wyutjS1=qIQ0GI2H6z(J z<&5>`-b!O2=)&Dr3uas@pn$>=t35x=T;7?jRS6b`!~NP_*P4A5K75$}Z(n-BzOd`< zCUKanDa@bxh8p(9>9Y$+%r$Y?t&b;r^KBaRK)b*+FOKq633T`Se3%}n+6NM`4Ztr? zT!7jHotWQGdw-{p+#d)cy(mGOz6Ut;j7CslyB=&gfYC7UqcPZ;U>xEbKIaDjhk-r4 zp@2t@27Gn^X(xyl$8ptTx52?17?|M;rYH|vA7GG({7{oLGwajh!+sYQ7$}`7opP)L zLF`|A^7OH`w$6@=i<7hj3X%?QZ<PP@Bn!_}C3&AauQJSJGl8PN1HRZAcs755jDs#F zE!Ty2P(NE~TD~b%f4z^Diw|_OLzF@8rvsFra;<`HaknT*4$}{Ckum=O{7#>)nb{$z zVL*u&4&AQlg8-surqpQz(3^uj&EpkCS#KRj!Ib94O;rm<Q}e-O$k7G_h^bB>*yQ>V z5fOlP#x~wlnk+HpsZIGTaj}$&X*pXXER)Eq&rLru+Ul(x_;I-X2`=sm7@B-=1Ni?w z1c(({aKU3Vk9@YQkBG0cn>yBVJvTI5ZRZaQj-IIA5vYWZoFLTa;1f|JQOU`_a>BbJ z=`{z7KHN)Kd?O;fN90$W09v8h8aF#2qOwk`b{rr*s6U+^Rc2d(8YtuPNdV;wm@3MY zCW_}UN6p25@uCSd!$1KMt$of!PVO}b#tUizq^ebEn+zm`evCO%v7@EpV6u8-bQH|t z0?rc`jJ|63SBu#G{cQ-wFEKPh9j-@{N2X&1GxZ22wP|_=dOkid0gE#WwP`J^zocS6 zxS7NN@-!M*Wqkva&NnJOHfBo$MSNb&=2J}iT|GVMj~<~ue8^tTWI3G`41&poT%W#N z0XAvkV>~=V1A~>#%{M>r6mkIC321rLBA~kkt%1o?E;%U9Q)pj*gU6^saQS~-lLQbf zhb#}(*y$-^;{b7OTj6#aaH@n8xQ(4%t<A=*z8$=D50w@P-1qTdP(R2|-`t$fd|i)+ zr#d8LAuVkQ;L#FdV#)9an44SE*)RSkiQ7|xvxf{fryK;fg_?XYHA|p4|NFST3-3Mf zOiWU)$-bS4;X~zowe5+rrt$xN;sOcur^{$X|K!^^I=<0xsbOlF?d=^IL5}SBdlAQ@ zny~*$5u_Ua;oJHBn+2l^{rz!<8^Z5@U&><puV_O`8oU9<ZVwOf|9gVeO$#fa1cb1D z0TM-}Y@ky`z9;(=FdC_CeX>CB8mamJm^;gdD7UcPgKj`cB?Li0QKUhnq#L9eQd+tM zq`ON%x*LWV7`i(oRJuhvB!;e`>nykK_dVx7IDAxe=9zh(weEG_*Y&&jwbYm*a}4kL zi%d*RTpVwFi38Y;t9gG(Tig3RJ#;CWDWVw1W8)rqtx9WaYw1lN12M^`>p0ms^c`jB zQq^~H6^q;h{dUhV=5esCr<#-N)D6)wbB1<j1N%;#j@=x#iWim5STM1|0P_Yh-N1hr z#f2Koc6}yQ-hV{Q`7HLXSJYJOPPJg&jkjGIt(mz&RjIo|b~;yA(_hhSBd4$u%jR=M zJk?G0p`bnquZgdU_0REd0St+8<(OFI$V?uYK!KJ<4Gqn5=|D*dG_-}Bz~}9L(?aLn zCA`jsps}mCgTg>f9sdXlI0(oBKefH~rC%}YL%!E;Z3d`n`ufjq?*Xy<4_lwS;sFL{ zjDnUH5#eh6$R~n2VCWu|v@%tn4Hlof-D!ND0-VeOujw20?{Z0E@QP>P1EC&eU0}mw zFg<ZG{&Q6l5+*KUDyv6|;(ilC9wJoNKjZ>S(|HjHGq!@{<>eTLNoztPBDN8{KD?Gx zm9s5RZ4WAzx1L@4OVR43A$}!b_)%v*p?#R`*KtRHZKCX_<?|OvVc8F9fL#T{wR-Gh zfT;fZi-_1%l4VH3#3X-uT63E^HwA`JH#zFf%gHe`_z;(nA-spRp&I{AOGD8|oNQpW z9}J3{OTk^zZV6`p;c#Xme$7tGd1a-s(z$x^z>^)#XYFuq%n}zj@jG`!>F<~S6N7^Q zYSXdNQOY23LSkY{UfZBisp?G3$WP5Xly4`V{qrvC4P1PzB_m@u_aFyB*sH4vcMbJ2 z=Cl!?PZOY{+O+i~HbqxuE%)A&pun;Ez=(s5e2mh*g$Zuq<{t%a!+FZQ=qz{V>A!S! zfq^{(9~C_>%{PF0iaFnE;&C>c)U+^=m*1VgD@jE~Nh#?1qhq#zZXh02NcF{wmt!rd zMSTp9%bGuyU9II6mqSFI(N)DRMejOa`J<tf@oEP?Kta*HL#Teh?3ELQS6TiF$XM1r zz2Ut8pl}w23Oa-;t*(8Us6s@bMt@~X@C>~MC6ySo)TqpS(NMCY>dyj{O_>$-k)OU8 z8?SoR?FmFggoKB24Nrpe)b?ye$;!LFbr#(aw<ocwso!pAYu2AqZs%C8;||K>c6<;Q z?{?lrS9%be5e?DQG_f$4E%((*RU4o)iuU-ep&6KQQqGovfyd)R=|VI4!}B*UIWxSv zC<GjQW^3ezzVo@9z3~nWtIFBNp0u8I+ke&HF67-c?Ay`R(Lr_VleHM-j&z_WBYQlW zYB5on9Gl}mJ~pJ+OnUrg`pbN2lMT&Opv})_|6Sf%n_p@RfeH<tG*4+)GD1QQqI^Bg zSN;7&Hw1WTP6sv!N=g`$@7%c~ZX2JROji=)94oQTtldCH!Z_0|{SaBY)$}HC)?Fx* zP8kl?1b<0+(9qV@Ogi0})6u!^DHFR&F{ra%kdv1h7-Gr7*+RTusFlOgBC}j-n7I6o zVEX5upkYrXyU^(9=<xWwCU?)we7TG!_>A)o>{wPxF2_#I3HSE<_oQf12ZtN7B>^_J zzP;Mp&hk(F{U6+jU%r&EE@dv!yr6w^Pe#Jzhp*_jf>&8K3ZdkA6fI;AWGSg438`U; z@$qx}O$6lRWQkF4FSEmI^1^FWFP%!jc+Xf*<cog+RgC*f^1*89xY$R8k47a$DFb42 z_}zJOd3Jn}2age3Bi4Hpxe#%yQ$%|%lf<cp#uRv>K+6qGJyN)imXiw!HDB)pd)4jc z=jJEG#c6724)>R6U8uAUB5lV8g|vco{RRaAqp3ubJ;!-~P=e)RAL8QSVIX0W5133= z`k5xWmB9^hUAZg2XvB$m-pUc|a8}EVq3bV0!p-XRF*6GZ7YZXN1m#$SZAx@f6TUn< zH7_yuoz~QJI71?SOo4qQ1f!(M<=pL^n31L?I=M6M$+2dgg*9DOl@w&3Z~y`o(|1;h zGfT|94%MB@@zuI}dSRC+ONrTXAi4DL@na3JiXrpC3debL0$+g=U@kjGaQ@c|u-277 zsB%!Ht2E+yhlm=P93$iQh#DU|-R4b;O`Z9k<2%0ZS7^Qw9GQ{ZG5qEFbGR7g<7*k| z%krWyw(F*z^}<oXvKR`<Z@O)^Ve*7XNJ?sIBM<LLtK?MXN=7F=H8)L*?KJDz7a8}& zqN?ZD1coQu)`eZ^SM%4w4p7k3B;z;uG)qz3bV|`X(3its;yI%x>#>^5d*gd?;r2XO z%I2!SxVUKYs)GuOa0E8&ovkTQca|O&Mbp;jNPDp(@n%bSQITF7nPe9j5%Rnq89=Du z_MDyOS~NGWP|EZL54QPOivh6{3JQ@g8y1R>2%<>KNQIr~5DODrq*#rlKzb_zmS?J1 zIb5cIw{)p(poE#392ODsD^QK;C?G`TK)1=(5?Wt(Pf`zaLqhe_V`ZBC#>bL+-shv5 z>^6;ii>wzv`e2}kt6p7elK(PUV+uGMRP>`VgS6Y?<I|Zm#<I97t;aZfj<Zk2x!@JU zTCs4IF}G2ARTb)AmNWgAZ6tvmXen<WqFdE5&73Nd`zXK){ODdjRUG$@igaI{$Yn)) z*V9vYa6jbugVnnUh-&a|;h^Y@n#Q$*8J<R)D{l#rDjBUI{wRYT0wD!=x#xX|BlD%F zexx(tNiHenW7VUG^f%8xbdHa+H$pgp1=;_Z|Ap$jpTET(;50SM9^30X=V8B1qM@S; zbb?H?2GAjQ5fbKuaUq6;4LrKG5LP`qeMt0nj*3pMBpqK&T2gv?mHiMZ{DE{S{oF2k z5ccN6OqH!@Pco6<Cwi*wWMnI+^%Na;r3CAvxq{xg+=_2O1H6Tt12AQ6kxcT57m~PS zKkFf)g?SK)y4yw8C57qoYIVhy-CFNOWX~@n!RT1n&=G6W8+hedxJY%#FA25kj_R3h z-j6D0u}=3J`7y(?q`m4&(|;ZfXG?ON%;38}2a2>KynDOjz6rw>F`e02S&E0%5}AZ^ z1y7CaL(>Z3)ygCJIc(_SAL2%LdCyjK)+;<PCH4fdQ&}CGgYwr>j<o`wgtig${F5*L z#1LX0l=q$PVk4D)rt0?7zAmj#eofv3Q$&`R%2HxVGW3Ba<<i^;8I0V=X)S6P1DPzH zL14u1d{jxFkyj-4g<4stUp+>WucIFsGMpysK^liyj(7uwDeiv*J@(D#$z8B}M=Bec z>Zs&F?x&OwZ*q0QtWkxtm1)95!W43^`Z{Jm^GB9PLUNl7Pf?c^@~f*I8b@TBSsne1 z@N>J;n|1lgT4~+<=XFO#d6cpqKXJ>%?AT+EW0$h3gN?D>YBYtM$llOgKNsP0+g(KU ze1}H3011o?Qi3z_o{U-^Y&54oB{2QebV89Ud7gQ`9E+bG?dP`_PQn@G<^CQ8MLH0u z$FeLCb#52w4U%<03yO>6kF*dl9n#CUV44u;Cv7}{BsX3<K{I7LpHqR22MoG3k;mdW zrJ9<2=K^wie$IQ~$wITT+DRBl{o(8gfR0c~&JC>6Gs|TP`0l8v5{u`?p(8wv>%?qv zW#zrKiQ0KyVT!A}Qm_5(((?-mX2%K|_=_=h*%3a4)XiZ&UZ*|SarT7S=ETpE*093D z`<_-fEvG*7PbXZ$v2hSEM>RH;q1g&cL8efP8?dd8k59-Fej-V#*X<>H?!sX%cDlCg z^se~;a(t(F`@=|`75p(hLkqBzFO(Z&s6swi6&KzS5{$vZ(`j{!g{jN6@XgKX6v~S) zoS6@7$c>!dY{Y&W+newhr1vxeb(&;EI#h%}0dIPSk+I)w!G-XV>u$#?Mq~$zcH=Zm zy-r%qrQ_$rI1imxyL6L$z3sqEq@!addmA(p$z?Mm$MFR8-hklsSVw1kVxprX$Inlc z&IL+ncM*3`W6R2zp*w<}d%WJsr|jmq6GSODTXkCwir!Z~w#T?s4Fxf^j_bOb^$Kh@ zZptdz{Pw$4a=HAs^VgDbx+6=nkmpuUc%H~-w!A%VnY+aGKJ=|JvgCkQk-vFiA!=%4 zD&t;X6sD}^B=6$#LR_AZ(R!}J`7i(yBZp7Bv%EpH(@)qxk}4;s;Ekm9+w4cSu9U9- zLXh0xSHqDFygz3ks^{Qnpc2;Va?X=ZkiuVfFn1oNPTR6fT()mL`W?)+oS3G`%&A#) z8ssf^AqR_M)iw&Ld=Z!!xa2Z&GB5%NuWjdpDeZhHo9eL>J*0q`M7H9dl`0Rj*$`~5 zsi44Y$oKiG@hV}%$@Y9;EoWdP;Xm3fyOQh4Ef^7lNu1d+JNvWB)r)66_DB<Rdq}bD zLn9B5<>aG8TFq&11j*_m^5H%E(XCOfEN5_z+#IXEzZ2Eqdj9Ysgv;i9tO2<gO(2SX zQeqUJ(d25m$5?U}v(YRkiE3=+pdxL;tiSZIStzq<91#GC6h8<~EefNugnkz+dqN(6 z^)8D%=tsZ_b*M^5S1{Q%gYv`{hO#ChA-=SJV_m(iv99iyobr#o9f-WTy;&~9X|bg) z+JbX=dA4DB@fq$H$kq=&;u`n_b;OTIU-yZxL#=pEfrz$^#?{@G^P_#<5w{gaFTqAI zL4zQhijCx4%_(3C>S9safZ;oj;E~Miun6rb>>A8_;R-|6pmapc%3yK3FWE_3kOmI2 zEhP1WY1H598kg}rwTjT*o^47(0;zmd-D4QYOi!5gbNXG$OnrK*<UCQG!F#A?RD;VP zO`GcU)RZ(1m%QWqix%}+=l8EXJy!_QpX_YR?5E&p6)H*VQBk{x+7d1g+rR||nv?FN zxN&8(2ej&qI%^c}ieII&Uc(u41tk^IvQq>JP7lqlsxifg=;7?vpa#7|3@>ftwt_yW zQBe>U_I2f-IWP>8S>p{|?5rERxR;t9!-!d40ADDQoa~+9_ztz1X_n7>zt_DE?d=R= z;@wcrxws9ecurl!gQUw8VA%8?sj+uoa!ah9N(IFkCHpGl-azt~1(A4x*Kn=Z2b>R2 z2;fEgo4u<=ljb+Ot?3LKHDX1*UC;f10y#fF$3J}~$3gqUd~Gm26sb$=^pIx+%+0Gm zj5tiRk<ragH9JnGC6DF$)sjM^BI;U5*|Q?QjWG!=;jVHNO<Lt|#u~MaY?6!FL2eLc zdN%z9*?hy*6}3>DN`ncW3r1S9&4oJH@P&VETVV+61PR?u7+BC}0~8cwWMGK!S~Aan zz#_kz8fdcSIZ)8}mahLxCruE}#?U%!dM^7p%{d?V>!mLVB_)orUnv|Kv%W9(q)4fB z0bxVk-n2rQ42$&n^955g471H0spw#Dp;s+>nwqN^bPLhbwbswHoBcS+nO<?RM2=SQ zI{SlD9Du!^dvzEJ+nVz5HrV&_E33N0pdWql!y|SwJ$6AIS)it{VI?Ahzq1r0r)egb zt)Bhv)ic(R<<HV?ulP?1nX;n%sLZqVo~5&=rIRO?o+S$0lcs)gmvNdwjJzHj2Q-#M zpWVs)Iou-P`7+LFMC$X8X5%9hqLNX2E}Nb7JR-f<<WH+RhjqnZiaq^-Q2R#MPrwsk zV`Nl1%z+hz?as|=6KW8M-?$ntuL;gL?ZVi&t*Cis10<xXX~tx3o2;?W6t<Id*0~Y; zxp>yoxfLP;^X+r-@*RZYd3mP82OOGEVj5UndULWfL8v=Lprq1MhWOgxPpNt%Llp71 zy>=@<&8Ni+Um3j@%=V=xe~6qa(X0yLB9EWOotS~py~{O}+a%5bD>p(pizAKCD=s4g zk?Ck}Z*{RORtFx?Q4Lm3<_r-al*{5H=^{tbwM07FueOoVH&VZN*((s}RhLNmKCm_Q zkT$@JQ+<&m^K(H*m%rIPBAW<{d;Cq#`pZ9_R4O<)+e2Qp7%aC^G>>){6~~;#<tsFu z0kZB<z}xY!OH-8lg)w{<XYa|$$uD9yDIauM6nigFsLGWUK)>_C_Se+Y5@7|h@u&@C zaC~G)<{QTdxRi>TQE|aKR3*a8FP|h18Y}fnQtDc9DD~$XUT(|{B1uQ0c|?fP{ZP#_ zv2MZFFD>E|y1Gj6a@TU-8_d(#3%b~>*?*fY{AZ$2sY`C2_H7L5ov%N?oBezb!33vP zsbl7R6_FIi<JJN-?$aHv4bS@<sx^4fPynG2uY|tU$c~RMSOBxNQk4bjx(jALzL+PX zJJ#>tf}W9;z4Tb_A+a;pWW>(QtAjJ)xVVFmILIig_<gJamiVe_>y2M@W89D{{4a#@ zO8vqL(U?{kCwp?4Smbm|(U4*Thg@Z=w2*Hxv1#%)Y51(u&4!XV#=TEZWmR|5LkCGj z+`8}2d@E@!QXwPAxEjsR`WZ%dchz4h$6&;^7o$C97L}GRtC5E&2#@?}z#Z+Xd^y@g zBj}qZ3mOKg8s~)4&6v5ZFgJe5P(!+iTFzCyYSWY5k@y=A!kdK|Svk31<a~1({BIec zi-KjK<i1iQ6)^HqN#t?6SoHOLHxd3s;O*P8H$0=RMklWrMQZDsl$;K~hLes;`E%}F z2kXbMjR|62T}z*IC58!BsA5qT?oL3n$0V?$)7z$b>C@efXLxBo-9CoKzECLphj}m} z)BB1dT?t5gd`5qZA`3|89mFxqY)nH9qt~H(^&ZXG4Wi`Sq28nGhTd=aS2m%F<U;L7 zxbxI<NwlQdYO{|^HpUi)p65^;Jzl|;kzLr>*$io|74RS&v<T`*w?Lldkofu0pMbq5 z{!!~O?VAEb2vUxp=cRo8NTOY6EQ0GJg~xE>u?YumIq3IrSx*6~;0tR)Hl<A(hYLu0 zBn8!}e}3pAg{Ecvn?5O`tM5O@uA?kioUiT@QA*1ScbLuz%`2`Fk{o<qgM>TZrxPKu zUHMtq8<}rGDP{-AI=#3qA1tUgpFh_^%{jHEr>K)55&IkhuZPO%8(L92F9MLXnB=2C znEpeBJNaE*F9h1gAG}YSjd+jO#i7dM_u`Vtj*xwvfHL>s!0xrPZ*A=(1q^1LhMPA$ z2@|9Zrf`cr6?xnfC1qOBSHVC2X;+?j<G`44f=U?l5B%As8&Sr-YaelhHxE+yyH)(! z(~NiLFy)46UKw@Ao@OTCs%d<+{V-i;LtA}eBJfb`;z8$gim}ogqow7vun5aSpByU8 zo1<Qg9_Wg?Q<vw<h@bFjuCx={2Ff%^Q3!?fOz^3GVs2b|8m^Q;T*i3hqi3gL17%`6 zbPQGsiGav8B;vuJH*7y(EP+~d5}~O#e_2D|t&7A%RuGuJ_;v-pZ+?WxJ(Mzz3)0io zbxM^Dx`YI~ujp%10e;+m!#1lY=|1mmXQ-q^?1c5G@}{#@#(VOp<Vd;r+^(us64~cj zeOsll+FAiM|42l9A$|kn<AtwIQpq$h(!yES2yMuP+B&feQS9gCNR>l{^}cP8HP6a4 z6A>pC&y}n0zTPlx5Ksb=s6Xt$oiBGU#y_GIgXWi<mUDO5FBqf~qp#Klm|WI~xO<cA z?Cp&ch0ck8)u2(@2#9}){9J85M}@rUoSkh3y`eM#=L%mfPa2xwi&A+zmI-y$tE&7t z1#23{X=?|<7cXB<dL%}P)U4K)Xla8}>!xjgj4_}N{ZxMa)={NVs)ieWBCIm9rG|`3 zm}Po8X34>(Tun#68AmU>UKOIYE#;&7<?1jW78b0K@0k(l`EvEfUvhiul?wAhDY^3= ze;hcKTL0q2vS;Ih$A>zRnNBGdzg)>EgtsV<xcaLKIYBlpq;Je?V+dM}diw>*FKB!$ z4l9q2p7N$rWzIHBJJm{$&ucWoW%BV7^A%krG0X`RApYb6C<#re4C!~xok8mp>Jwrb z)?DQzp12*C2NnoE{p>6CUG6kB|Ag4Iv|d8Bk$1n;5)^8sL_}i7`Ff{&HYjs^+8<n` zOH>l;m%kZu+f`s}MkOpdtE#G~ly$Gwcd2n!DO}Uk7CpTn41oB#cJ2*=cE***^5!-m zr4FE={A(Waffu@>(Q%zhaY5S`howzouY2m?h?$Ruk?2Tz<kqgY`FH;L$2)CXrUxf- zKxRRNX4DK3m6E!@_o93AL7;MTSl2H^5>Bc!-%*rH906PuJ#aR$MTm$-BP=TgcOrm^ zslTveajIT_AlL?WCWR1nSRSAH^7`(=+8Y0PR#j!ywe>=Y8Fvh2wIAhU0<Ogr9@~CU zanm(qDePOn-`6<^Bu!6$qE#2OfFWn=kyu_{uurNR=c&lSxVJ&30tMaq<Rp^IxkCk+ zp$B!@1RR8;lao2km4=s`BF@=7yq^fWc-fQHKTfcP1|yh;*YF!30I87b>$ce<*nO?0 z7*9G5j*>$x%h8Bzez%|9eUs*$`T2Qc;T9P{<q^&|yXy2%2})eAJQ0v$K;xkwT)Z-- zzf4}~hN=cikfI7B%-k+iHFmsmm*6+^YX!dUo|xsdqX6zN-u-FaSw9rApQ$d;rAnR1 zIs)0=#JPiIe?D29o0v5q;v3;-8e_4&0+wmy4E8nI!J_^f8)Mm-1Y9GYP*;U>>HQiw zvMSa~uXhfzkw^2H+;weMoCj}xop^XpsyUGO`e<dWzX<g7RLQgPQ5GA1Pie+EmMl-d zFZr){wfs_5ltE)gz0r<y8|IQ(>Ts&CJpk=TWL{OpH*eJ7hzLZJm#r=91m+57*W%=M zj%vv-WN1h*4!)O5V)NJL+$Pj7Kyz?WI!N#>+^8mG(<TTVebQ$B1n*v{`jFb@>yr+Q zxmGI%NlEu9*b$zl-d^&WVW@}E6IFQ$9LlaD9K!uTNxs4UUZ^JZoJW%bp~OZhdaKO- zkPh?BgG{-Nf@#sZBryK`=*GYbi8L%|l_X1^Ut77K<C@vj$oGj*xW^D6V}=W*F+Jm& zB~ynx0C@iu|8q88Udi37slx<On}&jdq}Ta_hXNYU>fEef%WT}8qowpXqrE!#m9+o; zo5LbT@NC=d(+~aMpT+AP7QfWpmRFr|$k>gl%<*IkQGiVoM-;SFIdCM%^7h1Aa=D(i zgOw&A;05R~Q!p!kcD!1dlGKmZ;dOqiFtpg2%;5R-1<#a#3t@)h7qoX~mNYNatp2Iq z{&3DTiO|cO<r^W<{TizCs*aSGdxykfu+3rB?JdHS0x*UGs;M){o*n%AZ3=YKdP?RU zDJ{&;xgR4)A5lo%N=OPs<X=Cd=GYle#&Woz;PCiJJ*fmVvgm2RV6~aDZ&;Yu;EGeW zb*c^ZGF_&Oa!q8v8Tk3TCng;+3=i}=b99J`t$Gm>EShljdZ6e#(!Y7Tzt>90mTR%+ zfqKX4%!@$9l1qIOaX#QhSaT#&y;mT?<?$Wm<-GO7=*Yt9myIIHT}g}`7;?!nIiW1O zFU6xHWn+Lkj&dk$lLD`k3h`|}H#g3nnE8_3l4@c@$yQ=n$ImOEtbiMj@><g3Y(+XQ z?)#K#z}j0o?w>OUGR-6T^&8IXqw!>MXWI1+j>A3z#nyOK;=_+*<QU*)iyvF5r^ayl z#E73%XV*RF{uJ|~p$~YkbFZ7FFO{hK2V`ezt%bRGLif<oGr64$v$7VyS19idCV#OA z9(AUVH=x~pp9lOuaSD=yheciqr7hIF+|y$lv|tQ3c%uI>S{54nG(8xk!EB_-hA=7A zZ#KP|Y;Gtb6nICGE`)gcnvEmcEl$rrMpax~(%Qm;=N2t5!@zSHUKh(nfbnkG^5G+8 zimuDg46nru`N_!iE9?Y$r%qL6c<HZyh}>!(sC5#)Pj7QL20H{ErFg)s`|x-ln1s^e zkviQ$8E+hoQR)#)Ps{Ka`5a&WNtNm0Z6gEedFuMpE8Wcw36V8?0y37Se=aTtSzKti zHERp&2cxLVuHQ<-M|rIBLRmTnx;EmSW4~6`J?un3tVm;c+}m<8j1D0n<NlBpQo(9f zknWCaBy|;|P(vty0=%3=0%qa@m7y8noOILf)pC{@8ab+?q)E@wt#PH1n2_-D#mk;S zg|^ZoW<F;5h<N6t`_JP{jeDk43Af`#HB_A!2CtO-J5u3&{%;>%A+k21vnf>{jqYPa zmxLDxq(#ws-<Y{(riY!q30FS3%zFV<4KpF1t}$3Xu%)*UQ?u`#F0}9;r)wlV(})uh z68IW5s``sj$6Y4Bx}$q4BqX>V3}=m?8UDB7cHNiwL6Q;&sO7VS6eu#b9@?&zd+j5m zrV4t2f#R3A6vnD?(Nec5J=~Jw-@YFBh1ZCnsvSGoVPV<3H@P{`yX@04a&yO<<oHM- zM0D@V$k@-f(t%p@1Tsx#JXBHBlJgqEW5e(9)?46}v4fQ^;~f-Th2Nf3i?Csxw{U~^ zAB<KQZ*JVyX1lSIWlF>)#LKi>e62Bcg?1VOux9upB6|9JUvqNCz*iK7+F@x;AH|&( zXMWKP9V!Ohd9F`I3FTm7;ul}LJXkH_N{adC6NV#{1wnMJ#_0CFQ-^OI3qZO+?u?-X zZgzZ6U*AH-mXFbkC8nDL?pJS+zWozl?Cg(z6fcNSIz;u8tPEX$r!{_8XG=EcC%P-c zuZ}3w&lZYhZw)gU!MOUcT9VyFwzndpD(qDuh}zQBavAw5BwTOfu;$ES*ge{fmXley zRsoU6Fu5VO^Rq^;)|Qe|R>#a#{Ts%Jy=zD$?ldZH-{*4qt&bPm;xAh}Ql`cpuAWo3 z11~ZjIX@kTV4#C~*`+EHAE}=%;4ZwG4J5jtp*M2L?|Q*k^fRaY-gNU8mlce(a$MA- zE}I9|+A5+RR)lW6qav*`0={j;+<|ooXuC1QO9hl^H%Vm<f;N0W-;t^+$KYs9qtX0C z9By^_Cty<`pMMwgOPviHE9?_~UAtXbpnM@Z`bCTT#p{DS6XzX%!iRK?Q3enQYr09@ z&TMKzLN*MhHDSCv@BVi4HRI#Q$*{psxU^)JMbckqVpMk0n>Cb2+6a;MXl*xHi7<>d zUHw&rB$n6Q*CXPx@0{zPpZRIKv|yIpaWcc-L3(MOORD;?88t<v&+Gz1f3*?+4iyFd z+ltWq1EHn)LEA%|Ysx}5J}Fh8uy}sINmLKn*L@bJFM0u~@um7apyfR0zW+(@K4>0( z-#eKjZ#t!UNv+JG6kP<P>*yVYW|ZJuGq+g(a=K8qcE17tOj%k)uO2j1h(YH(axyk4 zNmftRX8sUn_wB^$McE7EQ{?EOJKhtbTKoxn1lEP&>h&_|16tC|nVf7g8xv~cGBrrA z^2j|6M}Pbl-)nKX%;jZ*7tcQrlFu|Yz8P7U*F)OO-CtDqgIS2ab9u3PPsJ*q^2V5@ z{$@5(cJ{7#IOp+==r;<1&<C5(2<a$zs4&I)W#tGTM`?Xm9?WQCbg(yEUNlF3dIrE) zD%o=l>rYj=rMu0ZD|4_2Jd38M0RwI5%5DS8m*BSS(5V`-x-+*?PsDYw%1|8n&L{X6 zWIp24Pj7_Tk1;IA5lu)|CqetmaQ=Ygt;45o%%(IL3(VxnE@HI=u)zaM<wt~sLG$kA zp6ji^eh;^cB;kCUlu6~!S(3tLdm{Z9wJn8YRS&sd+Jdo)TQ91x0R$Wb#$8+r=?(3v zF2i4?q@|}C4OzsKkqHboHA{&1l$lXt6?aNvj25V-$y|43k2j~u95h0B<11C{LQCnV zlh2!S2Ab?oQ8h^yX6P*QRiXu0rWct9<vBKF)hDVPN|=*xr6Nb=x7IeP&RY@hvOa$m ze3~Th48SmUWxDG2JJZ!4CPdiR7OycP!IbH|k5a~4r`};EqNVye7<;=f_@k1C)|m8Y zvgFmCI`+<82hwci-ddeY!~-ydc*0YV>DO0yBEP#j^+|*>G|6V+zES8g<qA3uLKKyX z`X=sj%v6#=c1z*5Qc*x$hG4oKGp;EIRMf*xIaL^QXX1{@^$%x#JHNT&*$#cZ#hH6s zR<pgwky^VO<BlqZf`X<GOjs@@BkC>QlU9WYNGQe2aLR6YSZmV}gY37iuLlf>@F$#~ zyffK2U#Gls>Y5JM+Q5*ynD6Nlq*GQ+6O3;4h$P{OqQmnfTjQ_*R8UbTbj&+1BgiXE zI)79nu`e<Px}Gx}SYQ)i(DFArCZuKB`GV>i(w+LK$zxS>Uc-mx9+<w_%nVq3ZrxU5 z?9Q0X&uT4VQ$<HK*$KV>$k8}N`_L-y?5t~x%SJgsc<EszO+m&hla;6%vRX*VVtIWD zP9A-=zAJz7CO)Vtqj=AD?FB0{=9(0(Vo#Dwcuy1uzWw;hL&KGbVMJ)tql*t=9+JQr z%4icK!n<t}cF3WlY!eKw`;wiW3Ab`Tq0HBOFjpWU%|V*2Kknnz;=&uYzBxC>CBgUh zaW{{OFw`+81;51;zRJTrt=H_KV`p;l&qnwdI$@32Jx`$;mb;#w(m!0Z(7_D-3T~=R zPAN7tk*D0Wy`84zysM-l$;CrUTL-$ps+KgLAM0OyFpjB}RkFj}S+5=%mV2{|2yvL! zsIW*k%Qv<BT_$FA#R?Px{mp3j$rHE-CesZ%a*K}ppH#-_1%!Ac7{P0OSlz@yiX=Q* zGvC<onB@s2_2hMaUn8aQ_x?raA;#HUNPTULskI>?No`CiQZheN#M(Voj1G58D4n1l z6GV-RIkGm}-5oO0LRO4oTq~x%X_{2`x)l~~6PUxnIi~Z-B~PM6<K$FpX>`uR^TX|= zXM$%prDkLd<}q;5-i3AsoJeJJ{D|_sHmd-FJU3qMJ+`;5&Q9_Xfep#ao%3TD!ZeLw zP`p1B;YR}ub@Px4tF1@KO+qFqV~F%DWFwQ?EfQIUW1Q|#1zOWP5-{RMnD-^(gQ3CI zf|SBp^hWp1^gk#pyubBhHJ{+3rh__-r;E?7UrN7u21I^;dI5A7TFYU%H8s?#%D)GE zZHS{#u8H=U8uN+#cm-;!2)M7;T;7>atMc^BRIjdjYEGsSO(dn~%`(x2giMXKu6uTU zHs}P~wGECY8freB$fMI+93k?I-JL5+tt>TgQlIsXYW<<!Ds+)Bn`<lDHsdV1%a53Z zna=6aK$OZTbO`Wcb0{R0t0nbTT~mZ6?)Pe#b)x?hsK^l%jHXhyZ$6!?99&DJGrQS9 zne6Y`!fhkR?{xS9thY?Dc#kBAAf~3l0{l&MZxYxBIQ6%`kD0P4lIzy;hN#C%kdkix z)|6d@M8zoTl!`X}2t8GSzWFd(9~~T-4o%FmAQI<O>4v7mfs#hr%4nVPb-4{VpgU;i z=H^2vB)Q;vkf%RO<z(O*Kx?0f5lwY*x+9#Oy1;Y`hKpW`uWYsAOVej&da>`wHSHEG z`46QvAt)a(YUdB<_>xUr&^N41fhtf|PWH<BQW^JqQDHUz21$0JV(#cnrK|F^z1i*O zaG5NQ&kykqM2SqKt!T-M{4M7hjA2SpY59=}p#z3UY<&D!moc)J^D?~7A0+i{2fn>7 z+tg@OOWamlh(8g1$&zQWxZ8`@Gu>1`O|W;a!&3vZk_jeDzYCmcU=(>pqNw&&YgAR= z-}!XsgTA%b+7<OuuP2g)l$>zl9(z$;?AV|v>Z2m*BZdgh!-9wY8?a9+Mi2(g`T4Or zncznPa^?%Ah?=;{oY4)>i#S^(Z`Yhp*5`i4`O;XwW4GEL$g*q|hW8iLFKOSf({M%B zg5<OU<{AsG1R&Y$hU|RmxYuyk9?0NExCBEv5g0p~q(1ot1!r~QeFzzgHUWOkI;87v ze;g@Da3Ljo*tzbNn5SpGL99+-n3IS+)OQT3^)`8=T778NDR2tiR=xOMaF~!0Eqq*; zvMqRieoji7Vi)X|y?XTHBhubYPFI8-`jH!Ou^=lQn^3(&<*{V50K=+vbOy(d59lIV zH6V>*nH*NKz!RaLl^3c=#Kq5Fm0R64+M)erI9!*CmxhLcAy}RcnNUQ`H}WW_Wu$lX zFtDo?6xMNhG0F}c`_8b^d~0iKO)_B%)uB;%`AQ@5a(NZ`5F0TWhCkE0e&sx8f@x0& zBO5NG$rM3-MBe=MYtTe%Xi-*#vmFnOk%<Wi9D7^q4o|~(9tS>oo=!5vR^N$MoEVH( zd=NNyh`v1j2PKR7JMQW8tP3W}@|=_3Q&Gshi2=ffB=U*Il48cB4uqtQKm?<Tyr`(Z z*QokOm3%O+R^foV?i9pS1jki+yrh_sIY_39gLnuQ`}@DORau9nv@MI?Dms=*OFr?w z55d-~!sHCJ%zqiG<`6Ic->Mfh?d7b>FH_VJ6aQ&2e2ddMr@x}L$Q~S!^C)ocd$yA` zs@Ytf(UUjF7yU|4^Adw<#b-dfIC8IKNS;*TgWSffppW>dC@$UN{1Hpf+3PIFl)lK& zeF+PU#0G*pq@ho2&fUDLD}1VqaQ@AyX)|+kll7I^1H48LRrX4X>tch$<wFf{+6IVS zpz6~A^0CN}@LzZDYG@__rKF#)1>ks~>}!+H)JeUS4gKWd(Yc2^2Mo7hxE|<xH9My* ziA~;)f&#pzes_zPmq(XlVn!<uUq>H~!V<4gd`L&L%Ir3wd-kN87Mdw3z|)AWD+tcv zgn?$3mJJP!*c3k{8$6A&I2VB?*oApy;@Q{8!zRzkTWdvW3ky&c=c%)g{P;RO(cK{0 zq=p$W4@8(bFj#O<DGCZp81bu5ylAR{;@#ri)ZNs1*eIK!3Hd>_3HcEb2^W3bFBw!s zf$KPZC6ib8O#!Y4XvV5`vyc}QR}=x^uJMGqk9b*pqF&q(iwZUh%cqRe+Sbw&88`E1 zf7HG%I1?a%lDSd9Szi|w%77A|^)jLYiF9~i@JA2qvsn~|QdC{c$iPeaOAEDt6vxq# z;JK%xWA0zaSJtQne>*)h-9YbP(Uxs3iV!O>Tz?I6(@Y^*{u_*^qP~Ov4lS^o+Z7EP zMI|-bw-lH8%U|U(TsnP%6d-F@!nSAc@Vx&kTlSHLm!K5o=7Q{lc9{U&pUW{yf2G=4 zZWh$-04g)o-(!lhuhi4q1M^S(D_t%iS;cs_y2{8#Q-;fIeGjEqJlIN2p5pH-DYAkD zQ6Sm^Bxe$o<^XBwzQaST|6Y$CV&|i(rkkPnt_x)`PdxY)sp22!g^x7&oA}H~I7@q9 zIPOyO9rljDmJA|jKMK}B8Hz5>IhwffC+MFyA!JO;)=p1PuaB}X-Tt3*0xp^iWWo^E zyFTi7aQ~vc6v<O$rF#1`Y^CV_-#?bN^i@^)j4O#!7Po9*^xvn9ANS~ed;+u-YhRj< zS(qN@&$mY}Y_4-prtjmxYaew?T#)Dc`$zGiG*&rixr3_|`Kb8k>U}~L4SWK>lA@Fm zJ{kY>Ygtrr#P8#CD$v^*&@edu{8_=hJhXSKW<{v`7GH$_dkqnDRJFP9J%a!p?ey{O zM}7V1R}d|&nXxL{ur;Re&*3D0{w)ehv!<>e3IoWwIa-q=X3_l=5+NlcQ)DC$)=f#8 zZ-ePgpUHR!fvf62(YjA!re;G-<Ndr`Q#NB@3DK@L8;R}=E~<#RzBu*v-o3Qm1q45M z-zato>7P$`v0Hf#g#mCZ4!c5t3-U_Ao%b@M#ocN9XHWN3_m>f2EiJ92Dgf|$$@7w) zesgp+3?`oz8WO%V79Re2vQ%rP|64GsP|g34!Bn%Ls6RR}@FXKCfY44eKfGjsCewq* zZ1^b-PWZY&MRD==_odFZ>kF+9+KktQWo2!i>k(jBSEUY-lIrn8!zy^?Hcq>jFQ-wn zJ=?(Wbg{H4^)jwdnWVkyKS8|@y`m|-`3N+Suf=VB&Ka1F*&Gmx;-d^eOfK+oIEw%# zKbX#|E+@wX02XD`-j$@hAK%}`0Yr~%vWRUY)e*qc$Y*k<uX)>l#a6Qf7^phyDUrH@ z0$Md4kl&O~oyP6{Kd$V87ewS;*C#tO4uS?N-%l63GgZ?89;s=sB{3zX)_m;i)?{fF zX;?@^gU9)6A__@~a1z!ugUa4@L6`TpfmrK>AYk+_?F2}f(N(I^)Ik4u?(%Y>XAo{O z)CHuGZK+&^QHhz~;%InYUUURv4W(Q4tuZzG{0}fJD@cXC-C)_bp@SkYoPvMz>fBzH zv>?o+)!|^dp4D)7uI<~ck#vpm(OR!*J-`GZS``1YVW9M8L3Qq-;Q_Fe;RPA+#k7DV zDv+)ad_s;Lj=egV92@I?ycy4sSoVb5?hW9oPNY<s0s?S1s3FzaJM9UW|GO0x)8Odu zETB!LtvbJcEmQKjM8(8(NHV}e08%yr7dJ-dC^Rh0(7-SzHinvb5rnvIZ+l!I%>m{o z|3x^NJFpmIprKjKH16a?3dHa#0K-}IbIBwZl(M;|mB=$)O-;Aut`J}l3F7yNi%BuC zw$@!3uv+=_5Zt(v<8i<y8Xr&2>*jE@HlVMsFCHkqi!+OXP4dR8!$pnN4YQ>S@T4gC zJ(8lM^$jh@D#iQg#_~B1kVc`|C5^V7UqQAP8XCIsx{-U7M7T*acTUU-?%ylR;w*5$ zVvG!;xCQBTi0J2WbHGsUuOKTWpRYwdWHD8_T7U2h1BbNQ;M@+gJV{9|>6Pt#^N{oI z&x3jQy&m!f3&4K?&MEmpaMwA)Y1>l-Z^Lx()!9B1HvwsRk1{NQQG+VA{u=o!64-Ny zZkm~D-A_v$w<fDFJ-EGZ05&T?Fo!A!QERi%>aBJ=R#U{{o)K^s;Jn}o(z<v%;>Zjx zPj@eGf+;WX5dZ7_4uv~E12jU)S0A6wfd-W!)R00_Fn{~yi#NID<w8jyNp-R+#1wp9 z>1n_$OMZ+*zAJhB#%ys&@H#Fg#tuC1H+RwB@@wkqwmSBy(SN2CY=trqF>BXH!PGr3 zPj*K0ER{3!g?)vInYG{8oxPg-P+YR^eV(^+<$kig1iGzb`G8lkH=Hf1VzdHQGTbL` z_Jz?B&9ocrs1?eBaLEBLVrzNRt#9uK@D=*sxl89f(`qRBJW`@qiLd1~7uW4!5z9>7 zo>201_!ifXQXNqO!3#tur_~HdyZ&EOZT2XtnubQQV*We0a3{LM)<{mUp91VEkdCb6 z>;A(|I>>ahYt;uI$BpIgZ;?5G#Vir=#CG+NXbMKj_woICrLjs9pD~Cj^zNilN@KTM zc`GRii}x<eqHf|cdu7s3a-#_Pc4b|z35ki5AQlH;y$WY@r3l>2_wwhlT6Nk@W1ke< zY)@5zC5wS*-gB=7T<T#668Z4C#AhPB54S%)b17tUEBCBE1doO5OcAB`MWN|XhKIo| z>a|v#Ilo2M_iAJ{2r_6rUrYTTx14;zrs}<JX1zx3Sj&46nXi_{3&&z<6ilQ!^gF~F z+q|wXa)KjsDW<6?@+=qIeL+Gsu#!pfwenIc9<6ykJ06y#^!!<AEJOcczFI2DWn(lC z^opbh5}5TsNY<BJso2Zo*s*w<^!f(1nOZB5(#*#GTKjN4Qw7Atx$iHc7Ll~MJ-2nT zOGvXeHg??YIQMB6`VVu5qL>K0TbM1x7fZpEATqpS)6Uj0`Lvr@w$Ln@oYxWBkx(+E zZS_ikIRmc&yx)Hs6#9Dy8&}sQ0|C?gJExD5y87O94FeH~vWmfvJ?n}Ze$aTjzyGn^ zIZEd}Fhl0%uNbXsH#vo40#Zb5`oh2*@bm>>0^4@6!TkJu^Zo#r9_Y;cA)o-i6|uBx z(*pr3XUF|^-y-!g5Dm~|liA8y%Q@sZAJ3rvQXxeQ4QyLr-3Ls8a?Mv>S7&{R%sX>c zr@tZvM<yrN3F`KK>Z+-&t?tZKYp^v8Nq<sUM8k_jw=*>xE`P=Xg5x!2-YGwOaVF(M z?R*pTdJOQ3!M0FXZul)?C5~_kW@KXg+SZ2}sT5~yZqDsEJ?>~ZIahDnStm0IOa?XJ z!3AsqtC`w(_h`^^`uE32GY{?>a3<n`9)#Y&-9XpVb9=eV+X#sm1MYnwO~=5%Ak)&~ z<^3I=(L)1qs(SyWuK5WWanss@x9L!Fr-d*u6@y*^9TShk{xE_y$GoEQZ0EmTfHc@F z!KU<c3^&Lw4+cB(D+()`eH;KHSROY&c=U2J53#KI<?8jA@|cqAGXVjwzO{kkJCPIu zM)4PieP!D_PdKgSNr)ah_6<dHJ*g@$Ct-IP*0LE~St9-W_J!sYH_$l<zAEe=p4{vt z>g5<hH-;_^I&#Q)owVEZ?-Ef6xSWCSN%<KA8^bmv=~d@)cNmLq^IJ6OrbkSgU2&44 zqJ{u%RDH#y#d(CXwbB#YBh~Zr>&v?sxCID}prD}UsHC_RCYzwHTru5OuiRe4N~~JI z6O2zl@GkEN3mZG*g&Uhmzu7Yih>SE?0DwOTlqb&`U&y5jgj)mrT&u@MOlIc7@zx~p zEKHW@IIkh6a8fzH?~LC2dku<YVBZAsm%mPbFH~Tu8F?;Qs@rt(hQ4=UL5~FAa{h|O zU(54!&JPeN-wL$Y8V&(S5J)-IcLVu({up@ry=-p%Kl5Y~(Sa&|wq>STqpD<iX-4w% z6VPo}A5ktY&S2Me`*_>>(NRzeZ&&cK*4%V&3~>tkK`u%dK2HNyv-e)>IN=tI1u`^P z>by5!{8jT4<QeNTup@<o@HpYEs_n8o02XBXuYfBc0O=gOZD&v0K%yd8+805^p@7;; z?tKeZ5Noj1ftw<nkCgR5Xb*7ti8271t8uXjEw#>ice0F<ACh!41fP)ojZ}3DA%G5O z)rtj3f(1D+<m=ZI#dBrttuUVJIS{V2i|u?RKDIScw2nj`dIR>DEMKv^msjidRh`XT zjMsfKOH<+z`@47VBq>e(oc1{E0URCY{K^F;!T%}?MRoAuj*kAwzr6ChQ@8=f{_}FF zwU(3eB(yEQ?sqAsqI#5HE+CImU+F_Dq7MLSV7~Pi%je7aB6WrQ4_6lc!Fc4F%|@&Q zI$GsbVMQ{l3XiF*(VR}Ur{<cRW%wNeo(RYWe(}dRLO088s6SQ)1;eDCCpCv)%b54= zbsaSE&Bw)dkc*p30T5CY9Cj;NsCV(m`M%TF8gz$N7=8sQv05h=k&Vm32Rdmw|5emN zIuL-^uH%D#pk-srIphZAiQvWlbKQuLh=)*0rFZY5lAe-MhaMI`wrTB&3%6r;c0Tp< zyQ8Ua!af9_EuN}sa@dd#1Rz4UUte(|b12ykT#mrp_4?{)s1V?dQcu?g<2^#8Vri81 z!9us-ecQIUxCo|1fS!DD1RU&zrdkRo)Fwm8V-<$qfaSAcuSGH}JS05(xA&TOFf;Rw zZ3ifVmY07uo;j5mQG0rLTwPs(oX>+P(@X%1ItZy2>g}U-%dxvfz59P(6l*yxHng3Z ziOH%T!Rmc;tlk-f!;qZ(O-R-t&Ou&ME0`EF3^O(+2C$ctD#J5h-GKa3R@=5V5K<%P z>=inL*inokBqXU{oI(2x^r~F2x*p2Bx(0*Q1rRP4qmV4<eFLicQk|w+>m8oP<dhT> zqfa9I1O%}-TU;kwlZpJ~uRjB4A_(C+udgr^-3#<OUrm(tIBo|gU+JuZ4@O4SPCJ^? zNP3Om>sBNQs{ufBNk%@&PW5l`PY!eigeYBWW_}*K@$Wa+m;dpAd>WxprgAqVLRk1d z5e2{7c$;1u*nf2!U7{slDJ?HAKVs4B?*ktl2z+_aTW5W#p`pbJQ7-PVX*~~`@Y5F! z1~rJXJ}BtG{Ta&FJv_V!zLRutOZtLndSAZZ!OS4sq<^|*OZKx|?i#G$a-%ZO@LTxU zBj=)(4OQ^8oo>(lTG@VaOZ^%9R;MW;qhpc@l*ydrEnR2#32-U7sgSM3T`X-k7n7rT z6ciN5P1XulUsTjQYHA=A_h!8MwVvr69s2X{l7wOrPfAJKvZgdzm0Ss`;p1<V-h>hc zmDSbAg9BT8o2-(OiQgZx)y!xYA_=kEI`?9s8!%K%2Qv6wM|0!i2=%E3+RTqk@}+4F z!XhJqJGusFd(_M3?Ul2^odPVaZV6z@58|+uQ3hmDDUFS4Yip7CF*r4ZD%#pl9zGNf zyg1%cPU>FdFf=#m`unzY+2e7`-zq5`b_BNV%`}ze=8D-h+HfsQ6lG%Tw}N{DERazi zDhusCr_Kc1Gj&6KGXNy5`hT7JHc@}X2EuaFcpNu3Cjdm84-^`GAklny7+@Jg^YW+( zxa1n$Pnn5;eSFRPAaI`%n7sA8FT+E=eFe{l0g9sde;{NbB7hf9SYGqIIS1vhtLsq$ zQ*-Jw6cxPxsL6_GWtu*!@7~qBj;(E{0YpN|`NhR+lmgWM?AKYCx;5bR6!)9F`0r1_ zoje_NdAV>pI?TH$@ruVqYOTxCeZ9TXeY8m8YnHEneoG}XC5fkPGV6AHs!zcjHK8%Z z&D`}d=p~?$k8jsV(y12SqgH>@_V2;Dz0db{`6d@vnW`}dTgE?z5oaK=F3<1q4x?j@ z()b>V?lT9)s!1(~tt|j~|5@E#dg2p8yyIDzM@GlTN0l@msU*fde$NV6YR!Wf9MtM@ zI)A~9>R1yFh?Cb}-}P>bd`DR%I&`BL2N(ymKyi`<%zxjiu6~?;d>o+cn-#V2>0rjI z2~UiVzem}}bj88j1k1{mpUB^8ZofAt6i?N=S2ttCEB?`Y^R_e{TGTA6>FKgbVL3q| zLc&LXUvv^()lXVlFCy9-{90ilD-&qTxTp><U1^5<h;OJuY+3(a#Q-5u%M<l7r8N>R zo@q^N6y5t}6H9=C9)$e+-n3MnDP6Y)^kSFm*8Og}luxdIT+6Bb^Gy3pCT;5VODF&S zbW>VOIq&);J|X|U$ug8jYf<oEgXrJO^XXNO03Xdmex3A01pa4~r(wAIJW*3y+4c3g z*ppBG`S+LJf2NmI5jTKK_n<xsRKIXD3qae)&k;T2kjkd3r)P%DRsDHIfOJ@;kBPa< z2!-W@_dUGRoXgI^fp}n27Hoh$`{xk2&lqnNkavX=Mnbk(Sd{bpS-VUSt`>nP|A`x| z>2_Yd663$GD5L|ra*zRAHPA2vWd$4O`0s{UcIV0Q<IYmhSo`zd`SSAM?{whtC}Cz6 z1!S9BuTfm2NFF0b^!s$l;I0yX_D`23m8!<v9*gkOh(~klD@3nQ6s-ukw#+7%r-abr z6XO4TDJW6dDHD0qC6!7+d4<^-$+3D7SQB>(UN}O6_4o%Sw>z-Ry#6pC6z}(zvyZD5 zMaq|y;i!Hd_>7}lR4RqSf`x<Qs7&BL$<#t+Z2W2bPtS*5MGbBjUcLz(i)w64DXb9o z5{BpsKhrcoEw}@Y>t#hbu%ZE788d=EU#Q}JqGI-mxdJW~)gKchgMBRhEQ|&y`^&>$ z-fm1-+P4UE_x{(KQISPskaOhuz^0mngA@-Jzzhow3zZ4Aee4eCNZ=Am@(Ocu@@{xc z)eU}|x_|#DhD<i1B?#&0guiB-cP^~2uddI{tuAhs&3!TnxCoDM9HA1omIX!TAW6); z#p$k&>Ycw&wGj1#_fk319GesW{zt$6ulw3hfXn>P&;R$7^%QU#%-RHtAK1r0>`!%d z^-nBtM3eCs^()-(CO%3_Py4gOH$(qbDTZ1B4BlLuo&`YLH^%1~x46}YlX2{h!4fkv zDBP#O((876bJtf`7^Dow$H$vnS^^6A&zTv^i|WE~QrGfE$K#9RqxC`MrS!T5lYc*? z{mtLpaz%!Y=TywP_3wJH0XWG}BJ$?yNCD7hr}})B;R{~3hCMNDFK8YS5zP^S#QHtT zwp&i4?@#~S$bZ=T7Ks%V50_T`{C>wo=nf7JYFzK7fFSjoCbv8g2dnG~_E<irt2Zol z=3~=EA&J0E^Uft8GgI%~I~QHu`E70+8rX{=5PIo8q0{Wa!NAreddUeu3fmk`*I!HG z;>OjOJU@IW*3hA2Vrn5HNsEdSlZ>jXgPG<8b%=5|a0?5c0ys0c*&3ZT%&d&12!QL4 zlrr;Yl{+!eL8%dCpHj}Zde+IMKEj#ZRNn|O-*^nR%vYSOyR#MK|E_D)zajpLO(w0! zKQJi!iEbDf8If&d<=`L=q8o=ZK`4MSti^U4p%2ip`5l940)1l)fae*A<qZ<=v-TCi zd8|Vmq|4vL_JUgd5CkWIVaHDfCcZ;kJ0)q*z8&~!xdSM}<G+$5!byLY^C+pQIV~<$ z0I?4}$Y$W-`H6)GajUVqoChPj->6lfSXF!`D0usIU`FL0Zp%WRvb!45q|F%sXM&Sw z6CCwX3vHm@4^Fi|+h*K7b-)HKPzKeS)l9W+>xYeE4VweH-$8ii>w5IAnABnbM+D%n zvaG7<dM;{UGA16G!RMGl4D#OorKm^jZ_rSJJekm55WV)BzALTCig`y#Nr|LgIYded zI2oC_3=r+7^Ilb=mp?j0pFP8)ZK<rRO=I^W<X_od5h4IBCe1VUnu=QM?U8bL*+iqb zy^PEefV};?nkl0Ge&qKJ0JH9DWhHK|=~U6R^XmXehN{TU_FcgyT=*~zoJwm<9`z2O zR}8qB@)>;kSM|1bELX6ztG26)89*a0*zd$!aNZvFnyDo(y1h=!5J!!TuQ}e&_6JQD zW2<XfSoztRi>}!eG;g-<=vU+dV4(+Nehmtm&}C(Q_DmnZs6pYGnUwU_8)$iIt;gKf zK0lswcQ6diuJJr70rWuV*EdJ&LtA4_T>k1JA_lMLn%;0XHuA{u$0a3+oyw8AzdbhH zo-bi5N_Z(pIuibSN%gT10(Z~8K|x_5ljhmwhB6GmB(X3t!3A)9`1xRI1Q;Qb+1Ar| zJT>j4&CC$Zu6pyWJ8eb>B87RX<~J)VM!hiwfJO>%oHH1B6gPX>xw-L0YMCG#c+vMd z##hsDNP<Ql*!|D3)&ZKD;nHa_8_)Q^UI4)Tj><W?sV;y5ZZzsyC%}ya&{f-l2l{Ot z$oCR(+BQ4k1DG(wuMz7u`&(sgB5ud@Kxzkbx5wi4<g)L^(@0Ub%KA3~$wlLLP3NKy zwnz{f3TULVt1^i!Qt0oF+OAT91;CSieQ|Pfc!FE+eu^0R6}JtV^nLU5yCC^EL0d(2 zef{RjKDn)}4N#i77D@rG2w;sHH=K(=+aEwm_u6ik1aGgHKATL`9>=z7#yKUEc;6g+ zqm<?tfxnGcW8~s;{!JBb0bN(|A<*fbsA{o0o*v1O1W6$R=SVi7GG4H5jc?XAQTuUr zKNi48R}+s+0nd9v*1}?Xhu2rC2O1>!LoI<x>mo^=@1wi4MM|5uqMh_>D=rXqX=*Cj z<i=?y`A^tqk0*rqDH$2Y@3Z!{w-=YUjr4D1X$gok7rnW@13M?sNrZ$*)m`0HQXU0o zL^EF|O?Hmg>@k|}K|v>^_|ZT_Nm{cJ+_>>IfdF0hrON7p(8D|eN~P%w(8qp$R2ct< zySECfv+1@(iNP0wB!NKi1b3I<B)Ge~ySqcM0KpdS?(S|O!QI^@xVv>N{{8pvbDlmI zebL?fIqQmK>058rtWk50ImWFE&>aa4y|7X37Y+;&t;2eom7YJ6@2=|dI_F~$ldjI% zc4k;;=-AkVy<LT)UD;M<nXUKN^bxvp;Z^GFm3I-WtQiL-8sdN%J)7F<M8v{EL&ce# zGv8!SvcC9&+-`q7rS-l~u@rRF)>_VqnEbx)&ifl4?rOXp56Bc%nlm$VE{s;ONuSRa zJAQQM`6DjO?>1H#vS<N5hWjKD5fSsG0`P@kJm%x2f=*$T0Dzd6`A;l%23{1sw1y+K zxQDHXfJelvJ6vj=8vpIv+Yx}6%wTxo<?W-ZyM{*dP+1=)39Ow8Sz5hD3xT{V(65Zn zV7xg634VagEq`~9$T4u+oRLyF>_m~d4I%+~!rjIFz4%Hwv~#%mifSN{r`}*^yqMu! z7(+#l>D^L5aj_zZ2lw)&l=k1@_P&^hKbfiE-jA5i*S!X3U(106_v1TOEY)it|E-M; zaj>{ZN#lA?TjN*^FT@lWfB>Xw?coR5{UAvp1Oy};#<H;ac(SSK=0hpqvhf(X8V7hr zK>YAvtgvxB2{m7>d6VQ0gBlmoq?wumq(yuKqw0Nq7iq}JbB1@;^G|=BP1jweh2Nrd zh0)VJ&~)^6ch5WiH4>@#`#cty$bg6+_KSej19P}GHaJ*TUNb>&OV4t)LiHmG6&>03 zWOAc|Ld==~^3-GrWk<%m`Q_q^>}-7?+*ByzQtf)!-ZfJG3suFl;c&E`7YNZ@M_u{8 zoc+|41c3;!kWbG|WWs=4_tDu;u?MN~H4^j7*=qAYV~u4$^0I!f8;B<g!79njdDoCP zx5FXgp6`#zithF(X8!)o=fdAh;Rh33v9Ojq2n)A_k|NaT>&YX<$0yg?!U<H-pD@KZ zCiJXCBO`m5(-cmKp;f%p{l@e`eSjAas#4km`9mNW)G%Vc)N<MiQy6smrl_b`u}BHz zmO<S@0fa}@vSm-X7N?bpw^{&alqxry-2AL{^5>@;*q~6ncG?HxOzt;l7|eP?u_ehw z=<*XEL?tA+?1UKUV<R)yGW<M+nAI}M_CQADT6ZNQDQTWpFtmxz9Wzo|PL6RugoVs2 z^922r%k_4DrpUm?)&|Ii3d{rvzF@&i%>jMZfNBH8bAi29b_uon>!|gp@fQJMV+Px! z`R+cj7B?;pVY)g)NzETWqM8_(%;wm?(H;Z>J*dflx3}#t_xHeN)rTDX<BRRZ=y9as z&yWyooIOHx(bt&zVn9ZQ@xsW+LK_VQ#qo3!r`PBL=L@|@|39BiSZxp9XDb4%#p%`v z&e>oJE?pqkIV^;Y#?&sxQJM+A(oB%4<h%`)a^Zhtwz6h!to<%E+;0Cy++~4RFbo-s zBQ2v)Os2ESbm;5sKOF%`>$-UqKbXm~6(=VZ_UCHsZ0tx$Nq_zb0tO_#e!+@HhbLx) zn4Za#Gqs{!rYv_B^G#y~{FO+ch0_{KpdppR8ljoAnemUI58S!EwM|xFG$UFg5aF5g zhJ1nVo>x=<JC^lwr?Z&-63^e*$oS%LtTKbd-*-OuOhi*N?3-|-OHGuaw2AX}@3|{7 zu5_LV9tso+d8zmm0+GZfA=n#9m3(09bo9+mtiRbcBZe5H5-cpvvo|eA<^ys`VyQI? z7VjXyZ6kXB^%s-%e>DC<LW)T$$;_-Y?r6YauK~465gR8;_-Kd2MF#7WC@yz6LZW;0 z4te`OAcx<TNK<Vx0nrdO@$iV|9l1lNVZf+5HyHBRTJ;N-d$_BVTyowrSI|W-)fnm- zNKew?d0a$2FfuZlo}0PYUszaN41JGAEOPvic>OnUEGiAC$O<V($LGH;_Iy!$6SXGw z(`IFKVm^sPJf2jA&EtfU%49nH0U+iy(%;?P!@2p6sVNJjh^)>xN#kt8HRl$ib*8bQ z08pV$f^s(^?sQm8YEsgwV_)hJue0p<ni2Wg(Z1OBhYv>O$!t~ov-K~U`5Yu9<TY=4 zCw8mc9BXgxT`tS2%hRW_Yq|BLxG6+~kB;(gk$<gQQ1{M~e#`jvL))h?N@KzE>c{g> zDyiB&w=7nUnc{{zI+QAO)_v7$B!f{=Kadbr5JjY!82JLD8~puiJ}&tg5xmhi7pHoV z;wMwJuvpAz$Url{zd1WH-r>h;Z{u0&D?Cnna5s8=dBB;4L9Q}UadN_3oQRA?-v)#^ zKtl>=xp1VwfIA;X)q0*e6!uGD4L^s*dUzMi_sXiu$3XBD8|tzC^CO+e1sN*Kusgt} z8X74rEgte)v2J<N1gyH3afykl%BmzJM1b)R8kFein9*=Eq|$)bgWWA~9b5p;?H4$Y zyHll_K%!g_0RZL>!Jo6uws425iu)`ThKF1$83Es;#>YuSQ8W{zdVvh_=pWus(y-7f zDy_bd*jqnqbvDOvGfRHeHZb9T{2=XN4Gs>Dd>=_BU<^d;*6l>xl9I9U{QO7=i5de5 zT0v*3%T!6pU{tX1pfGDU=zaq*3&?jm{d2z5XTML#O2RqYqMk_5B?0d)0XA_bB^317 zPtN8olalO9oX+g<t#)Q=&r$5#O+Uv!lP2UN8=A3=1?2}UXATAhRL!fw1vujeX#!a3 z%|8YBTpDNIHAlt*$`J57W=OL{v&0x#`Aw2dzbi!B$dCxS!uJv8swyjsCD0}?e{X0| z;U6;Jq1vK<b#PFEPOUC1CZ_!~^oh`;T$7Nu_tz$^bH+9i@%vI@u^B0%A#ur}wE5hE zQ&9<PR|h+jukPqude*81M`dN31BrcuJblOen|)ofx#1u~xzQI}oTdg033V=0nr%1x z3nsCIW%V8qtfh!5eMwNrGH1%P*FeQZX#gzWoF7vG-$?TVK!*?*1ZRUTBI3YBvOhqO z-%7Y=^=lWIpLOBkFTU+WBU&ey+;+(T+xI|HENDwC$BaMD$iM{u3<Xt5C5XC8upa3# z@}#;m1Pd0{e{aUKuB9-zF|p9cos%IxV)KKW@vIl<&RzwYj~GKOv2quCTIOnEVrp7e zq*QvVR}nygmCC%U#9hq4JVN>r5RX}<n{*TuY)WvAjP)fFLp7uWpDBEI>Waa})%bL) ztVqbtPC`V)l)74y@oiR0Y#h7vClqV*m_v`ONBFN`VNbrDqDjII)6w6Q{u!&t_Uz%x z&Z^>*osS3#Km~^!8L^@}56N%3q;LUSm#-M;m{Yh9ZI26Sa&LjT&k;;vujTJ6;TK5` z=5t~RjQJVY=ZI%<9PTccTpp`0KeRj;e60lAER$)c$tNbN<dZKz2D|4%Dk&iWv)Ib! z#Pb-tRp2v9*ao4aF<Fmf1?xi*z_{>RV&c&VW&H!|YIxzAjI`_|f<_DzjRB=8B^Fvz zMoLb6aZXpZ3Aur}d5($Uo@7^}XPwunBofKRS~ZOMWGtx;1_($kG#|7^n12R_#H#<O zS8kEotdF~MBv0u!l!{r;g4aB#=i_ZvZ~i)`{BaQ%I;VCJKw{Jj-2j}3-2P(s^1)u< zwUNwn;6xR8xKC+!_;WaZhK@#_m@YQ4h5CCGiU!=I=xQcQ^cX<?xV<nZ|A>WkebtSZ z-vHnoN1F@kMV<?Vf$piE2Pl3Nv_v3tyFIRjaRH%v$y&1+1v$C1&BV%3JZ>F@;_&Yb zjBn4c`i6#RC@3OvDmYmV<Fz4I8y8j8)v(BWB+Y3jHaB*NXJ>?4cDDA$i%t3StOEn$ z6waTiX=qYglirq$Jd9<_-U3+yV7`NbgN23rE*b)H0D=4HM>lBYIz<p|k41yk2NGyy z^D}z2uo%Owm*s1@hx3S8>Ya0i<+SRc&e&NMSaDitdRP0FfY%3<Yg}?Go>Ab1_I9_0 zlZBOE?S#xsh@>ImvOO$I$F;Pi80sREz`4$5u{E&yz4B|k#L;<Lnte}8^CK{`dRQMD za;jGxN|}^gON2$7FD@<yrITMB1R(22MVT^IoDbG%gVV7L@Eq~>_Fn%TnVV8IY&^Q& z^Mjixn@G1yTx-=k+(NAtpZ$RS^*;#9uNqy>3u<)$0$%q67&R(3R*~_aS~W1)a&8YF zFV%T?w4|hHUs)YCT|VR>?iN(N6-Jf$h>0m>6C0DFZG0?t$lM!tl7h39$ihB4F=21* zP~=w5ku^5q`H+-kW=leBB1&Y!tEW%4@H%u9Us2Jp!G3!jm>TT?O*b8#H3EDBEM_ak zyX@@{EG!IteZ$1W_>-codKbIH(do>u%EmJzYv8h3Esc#ehAvtilyY*2qq!G4C)rqu zn24Ar+7~5uK64$#7l(Y1#dTr5u)|V#ooyVYA+5car@guPsGk`~?@kU~oq&-^?h6BB zUkv=BfeJE%;Q+|k=&w>cao?XcALTVP+#Id&I$syp8XO-7z64cVi}AX^#Kx{Ty0rq9 z)-1rm`pmaRYL>uq_(sEGBI6ZJTteKDzapsc08^k;v&Vd(8O~%nd29oIE*MZ$Cnj@p z6KqsW%$v2LR-xwK1)d6H{dTH#R&<3h{6ddKH;G|vo)`Ty{J}A0n9ChI0lf&OMC|@i zf#ne?DHdRxvw@rmEdpj~g!BkTyz-qSmNgp6{g;}uG9i<}x~rE7m1$`_HaZ|9;sf{? zn+VOGslq>3qY@)UhqBYeZE#0EZLCso=j1A0XPf_#p6V#G%AX-^PoL2{#ne4s-UX)$ zkP*pNcX{;IjaKWHL>tOISn`ykc2*YbG|K!F*yV_xuu1mH({Y53E?$xwwFKBPY^A&a zJ!G-7kmp=~flW!io(sQG;u9Al&mEdUk!l2<gwzO3@=8*1;o)!JyVSu<sQo(^Ktr%5 zOr)%-`B>T-GwwQT5_r2eCi(mTR;}J;f2Vpkv+E}ohm%y|WbqgSJ+)@b-4Kv<PFpEv zSt`1?drl(Q1(IE0AubdeN&@k2;0cGz?Lp7Z9)N2yS%iVt8<fa$SP2T3vcJn54<A5H zNFtu2r`fx)IoW-DEw8OD4S4=+tpAXq!oo`Ii6l{Q{i9H<Lg#c;mX(#IFB;qRrxU20 zGaIjBprBL&LuMCs;)6#f?Yl1ubU<O5$dfvO?HrN(vD`|!FNO+u-2E!Zc4jCAfnb-# zLi%j%X#2x1X>py^anN2rm0JSP1#`b_=~(MTV4!oRzfyGj_UoWI03Mz%Gm+t#PxOtg zp<!2E-rpAip-4C!JRj4@KYhCTy=@CL3@w}KludfNB$B{>_+hmG^sRt?E0DX{URW2F zY@TmwqMCCsH8T2%$Dr2jLOKx?`VLWVb7$vwF6qN47uO;<^$lv)=@{t9XmVU|px6@( z%s4J9ebdp29mF9QiE?Xn5R(v2pW?LoI~^y@yWzkbHIe3V53XUX(x?L{C8lr(Uxa{O zDH)Hc?v7+f4>>ej2HBts(v&&Rztex;`H-FIt+sE14h12s+?3*wdNE0XL4|ocpbPDD z@hts=s*=<C#IDB^mB<7S8v1AXuS2c!zmfsFVns^L$-IbwaXa9j0>qPXx!mMHzG)!Q z!!9yGyZyVR%h{gB{`ITV)}&gKi5*p-6bk(0Y#OqoCt3>?PZ~I<>Seo%RhgSl59Wa* z^7M2apfmA17=!G`<7KKmgg_EtcT%ajCgb~~cbTMonZJVq6rRK1GFvEF>Y8?tPc$l) z)-J{KzWeyc+_Lp_@mN=IPyZ%|?AOFZy=~V8I*lf$)lnR<+DJ|o|MpErz#v9a_Hf$w zYCQ|J=O7ShTmwG<cm()+!vO&bQ&9@rQ*iwCU~P3USB2+IEX>bG#F%#69c%`z?=dAx z=88j;Jeo9QtVC3tKHn2tnq8#miRzJngj;WKAG6u4g|V@Pwgnmv){raB#rDL)jd2&7 zsVM~j>S4cr)gDZnS_;zWi)-sR@uBMT;36*gu8SVWI@na@Zs2ixm<&C1+=YN7?C(w@ zFcLt)r|Rgq#3*>g2KM5x(z-7c<OUcFX)g9gqRHCYhPBqqzN$Lz0fSBMPo%$l+CKw_ zW@;{PE-pd1TMu^^dVQ?n)~QhSThNvmd*9p%0;;Ge1u=6?UY?SqWNo5lZcqxK@l55V z-!{XAIqb7)DGvE_h57!@idrA9F;|i4?SXDlX)(rbxBImSFw6EbYBIk}mi~ywLhZ+x zBEG!@DPtBE7U10wwE$R!Dbdl{^SQe_f6sQuNJv;%C@Guj>f$7`ywXIcsQY3cShaW_ z6Z$<@*M(E^0b_OVzTUR4S7dt#lAHLaHt`U`?>Q_Y((GGVd3vPH+tEP)fxOSJp}7t( zF5d5cM-W5v*vDtkmRZp9Zk7vxb`cS=&>ugpEI&6i0LGN^^`%XInI(Xx(h<^fag5gA z^(xasab`7J9e~IUF*o<p&>@(l1VDURX@YfnDl_b-ObYPOj2f>Rt*E##SG7MF54Ayq zhcq>Fg#3KK(rg0la?OAPq%0!?&*>*aD3T164vmlZ(zY2TeQrvk20_<~G|0XW(2!bL zTVoEF=4Sprhm(~V#pQypEThF6E>$!ZHs)K`P56c-wZzkE8jJMpl%ga;@CB9mepHm4 zi#mK5L{`T551WJ0Sha1JNfRT=rDWD8$fBr=fQycfje$W-Ns2`yb8&;?E+s~}pYLLB z049scC$(`&Rn@-sbPcO+ca@fi^z;y&4#n-=T~^i^&_X`q@l7)8YP54U0d1~OP*Eh7 zaO)>fEDj8;tS#NzBjkiAN*y(W_N1XsT$xOZ1w@3w5AI4KWIxwrjV^5+Z3hPWL^IE* z85lSTd=^p7y|sB^m6RV?WK{LWb>Zs)r4t)F<z*Vk&wW7ukexjyOCQPiI>%+mLLHOY zs?mOny4Y|RWR=XS9=^7xqxLhMTKs}eAq|ypiS8fLiJ8`40u6K;npUr74&Y@6+ze16 zA$cN)>+7WQsili|cP8#~N{tRocn?igRgCoX<SZ;xJu^My>#Ug;W8Bzbc-%-xuLHv* zjg}7<vjTJR3Az-|L-h3Z1-tmhnj<qalhvC|kh?T&!8u%v_faV!K7q6X^Uoh5(3uQG zglA`Gqle;SV<q1~@&n7wTF_h$S5`7f<`;I5apuEHU-(ghu8+=6f#@P$ZS8mFbb!OP zWw9utq4M5+62xg2mxkW`F2&cg<DlFDa`QzO(ng|*!2r^PGSc`xr*w%ak63v#PX$FJ zCW$R6)UrfSNuRXQJo>{;g(Y#0PmbpOL()O%SF(Ad2b7%!83hyq!r~~Bk~z;w3up_q z)ClYU=7OFGzmivgqAtk7znNtp-taL)jg4)#*WLK0h>9xpu|b6J=O+a?kSfm>&{GUo z{_uhZI>LFZPg2*=KmXrW&(I%nG&?devX&WUTCk@1?8C<D9<BELKR7rOn2t|xSmtm4 zbO2uDTn}pv_w+w`t_93L*#En41<e0bP5ysbe*XXGZ)ks<X+(x0DR2*4Bk8fIxqKC~ z=!!yy-~0xD`t4V_z2-}s(jiy#sYU5`(&fX!`0()GMIy+XFS9B2T?Y<`ogei+b((3< zh&#ZY+{eYTvr|Q4XbkpS4zm7>{E%k)Z;Ht<)4$m$e=+~fGO-K!H)X#;0Gv^u9_j?3 zo&tS%mAv~8Ljazse=}YF_kCP7%qy5Lo>ZFUHus(4;D}PYM6Uo{4Q=&-;5WJ*w6*)- zd9u+lFcbn-mL&IsQ28n7q-=SZY@`oE5ajbUB_MJ#QEJu5><pQ$w!lF_+2?vZi?^LD z*0e-oeB7!x6uJJZ+ZATHP{T>uT?D<p<s^P^&0GLhs8GeTxm|U)+}~m`8%f#WHZGJ5 z5U9PtCnB=n*TmYSx!QPWJS>tg%rSLKA(Av;NEFV2en8SA7?!r$B8A=A^Idh%5|!qX zX_&rj{z^bIKetAhk#`a3JZN@REKwm)o^V~`^0<0%H670b9bMqEhK2*#VXh`?%-I3^ z1rfHx>%o0v2zavzWy$y60rX;-->kxX=pwzfE2!zZJHXQIq})@$WcozexWY5hdgf(m zoG*KtBYtzSQ~q~~Xl7<cwZ`0XD#w0i%Ia&Q!}-o6XIxyJ>k#e+V!pAlZ0-<w720H; z!u<|K09Ht@g#k9VwazhwdtH{0vQV|u0({b3mB~uMDG+gI+D`0o(6<CS>_b3YQBg@s z>S+5njk$yB++?xK<zD*J1r+81XwMaum{@CliiYvm>g#P@ULJ7>X7ABXWvNhhwh*vo zshHa@&>;-C(V8botTh9+I~a^<r4-F<2~lS^s)zUGX4fNGy77PlS8I`Yzi?UFa((ge z0<H=JAODyB9dK!ky=xiQO`dZ+m~Sv|6f4juPI#J{me47R2OJJ{wYAfI`2Z(7f}7pW zh~_Yn*>YfDfV1#i&p@x+wff4*ryZbZ5|T@wur54yj|>&Jd7sM#qWA=6E>2GOc@h-8 zLKC@ChlT1Xj?;A@r~6U1)*|`t?z<${Jur*WGq0gerjEJ<2%)dfy&8+zBIfc(_m9ni z1ajyI95I6hb@aLTUJImM@PBngSJI^sO1(F}ulCTLQN(v1OL4!|PZM4fD0og8a~IYn z`Ni!@$jxp3#pMDH&WHU3M#F=!{Cwkg?^Z77?OkhwX$s^S(rvqt9G9$3&CJ9=!7z+_ zV9H?EZpBaKdyV#(!BhovqB*cUqaUvCcvW%(j!<9{^Q#%dc$EqfR+-jjP8GbFtDWlP z6A)lDU;QV&cRGlcg2MH_Gh~hHp$Z%eA(<d`ly?Fd-y0j^T55dcaZ}s#xm?6S!P(~m z-S5-cV}91<<t4xa>>a>Jc4C}ye`g;+H9UVzzJ&38NsmJ>&z1zvzosBea@`BB(jtvU ziA8Z@-CGAwI7u98qgLCg6m{1NpmN{Y`5RT;Wbl%^Klw6}dCx^7JxftnAjE7NA*{Du zrze%xdh{Z>Q4cJM!=S)xL6>Nd+FAWAj4@s3T*yk=?LI1CC`BZzpE5lBcH^IVKoO`} z10le8+gJsM+C0S<a{|~AXF^Ay4Gy{r6%|UYUn(gSnxBlvmqf%X$IrGUFzv!3?rH9Q zFPRtd^?k`=GJdog%%(Y9l3d_5pjvD4f<mFB!K_>BQdlq)cLJ6$@5cCn<_~p>-JVjW zKcZfm``l#v*RRfKeVyW}F6Vzsum@hVkQHb`gVGTZ^wtOd{>>{G38`8+#|qv)HknAM z{sXIo!(Mf`R2#;OZNyA6o~O|sI>&^#GeuK{hRbH>^zhv;WM|59q9EH;=4^Y@m$N~A zzQO*@W%eF(<H)j+nMUSKe%G6Xfla_TEm5mBSnC{kuhj!DD<h-tmK%zL3ccPdSP;Fh zd?!7m5b)Rtf6rVE>TjQLgMKN9-Cmuq8h_%#1TgH=Xm048J80Yrz2oSkuxv?=kGS5L zicEFeTbZ7gMf|w7rqj{U0U{6RG6kxy?w#@g!b(3cN^ZQ=+gdl9-ZSEcs;gfPRzknB zt?@6AEo~Yys4UjFj76w2nb8?;djQI$ww?=)Q#hb+K;CpRU*sLC9ug9K(9fVI{XN8! zk@p<pS8g(3g_^=r3C@)n+XCs{+wX#**y=4h*-b?XiKcoW=->}HaQ!<Mkdc|O&TcW& zAig`${AM2xI?`9~!PPn~0jo5625fhAb*Hbl8L|ZUzldHyyqR_%ARmR~Gjc+()Va*^ z5U|C>lIV8Xr>?-k3Nxe=+#OA^n~Qa@j@N7U*}3nWe$~q6md=&6>)kXqh5sXFO8aGd zw+CVsFP8I$I#25HX+cS`CA@v^by=)Yslb}TZDcZ4BVNv6SC@A0@VcI!0kmbz6zIQ^ z4+NqQm5zrkA&Iq=>Ma@C9kA&34st0F^SXms(-y!|>BtcWmft@sluk}4(c)22aO@Yl zO*s@svt_&dTB%3;!TiHO!z_3jT|;Oj)i5YuFLh)tG9AfK{>$y!Y6F_%i%0%mz0%nD z*+4#ns4MWNtE;CCxYq&-@A*H!O3>|FRvM%y3jXw2xd=35DS|z-*~QNDZUs)gS-*a~ zK)w=el(SY_66pVmRzyAv7K$*-k~TJ{s3M>Iz5&B!V7lz&<RoTiM~2MfwzhwHiTC9# zF7CzZ=_ArVX<SfjXr=KQ@wI}>FCsXYWlM+>KWjACoWFP?VKc-V91<fno)!aB2^$QI zq4wh4E8tYQ(4rfF5D*XmD)s8nXT8FpC}Qx}>YrBA<i@Eyjr|%`3wR`WpnL|Rmo7jj zUX`?F)7j4iWI^}M0$cUKCeITRX%QqjfVo2gVFtXAXZvh2uu&TNf#AXRe;cg)&!7|) z&odZu#jhS79$#AqOsZ%092&)+20#YZXT*r{qd2i`+MS_-71UWN`nN%)GME`L98Z_Z z-@R{MD6d?sP`_1Nf%p~wixNH6D-dDwUOF_Y+fWFX>hV0-q4BnZHwEEG_GCfFfQL1M zqRafSbz@^ob$#_K^(O>apA$>ieG_<?J3$GH>`8A(Bk7E?vY(%y%(}0?zW^Vf)f?!y z@oW?J^!E4n_w+=}6Mv$m1?ux!dE>i3hQqb8Nx<#6lhB7%vUx+qP9fWy($>~CJ*~8( z3cV0g_WCp-zzrrP4R2fq8t2i@U(;)0f>pq6q=2XzZ_5h_f$fdsiB;-ViI4uyhU_ys zYDw^$jZ$cYCfRdvbx?>wrEPC-r=z1|RE8cFq>!Fol*>>=*=jB%2*qRCJ$;obZ$RxL zyG$Cq0!HfD$jFF|_2<}+HtFeiPvIbS9t*I6qE(5?%ge``p^!l8s|qKjbL<<$M>8QI z@F|P2v56Fiprm)^3ccnwis#RtufOL_Zkaz~0oS;!?~q8vy`;m4NkBmGw4XT%^YQtr z8Fg>?FFcZKgDLA3`vDcL*VW33HsmP~IYneCf*&BEX?OJj8vHAMeShxEk~jbl{f$i` zjl;v#Y<D7FfQl47u~Ucf{^Q3y=re7F&!1xphdPX`C_)FJ&&;9GM&Upn`qZ-gtZhbB zFj6bDJfx9xp4)x;uWw3r$el-Kw<c-(7Y_fza<!kCVYKqEf5H5(<8u}8t6;Wb_mbR1 zJ+!sb$nYtJp!ai&3tY!={@(oE9=7QAY21q!83eqjb_ARZG!$Jr)w4U3Kj}V-BvDZj zun<s4!hZLef1~5WIt+sRKk)FhXv%eI!wCr~1TW~M)4iV&%NMALewDCjDN1@eQf$FJ zefY0$swvr0*D@a7@;dYwKSKXuj(T64X13=L9T81&h)+VpP5E1GH0k<PZfH^0=fO%; zqZbevXFW;gMD;{vSm=JYYzW4U=P(0bq)7L}TdDLCgg5ZT@q;cr5i9M)Cx!5l!@VAC zYQ2NyQSY_t>lD)>^$N;{6HLj%qm81A^SGgR-{?O+;roZdS1OT`kv&G$Y$ij><_?kN zRcf?fNO>}N!y<sRlt~OFHHLzg7u^?9Dd+%sgyjNS!SbugG4Z>CKmMjnYlG8%%H@Qo zL1C|_ZrXbLsfVD=XI`hsOl2_391)sU1(Am))+@aS-CUH{uVUN^U*dav=j8o(YrDL} z>6vS|DKfjdBfhOC62tr!2jjioL!(jIoV-FP|M!oA=J<`AZF=ypk$(PzOs!J>ce+Q+ z+}c))M*t`iWPfT+qa~ci#zRrqo9u;;l&uW*$PhJ9(2&sZ?$NW;^fBXAknnJ@@X$`5 zu&C^ltDG5;bRJ>KGGwdy+KE2b(_B+sA*tq`$KGnt@>0@zM7zSU!j>u&u&PxDb|MV- z=+ra+$UmzP_Rc8_|1{H4@hjs)QSUD!#%}+fsoHhddK5<1)gI%S6Y57ooy>0wBt2dF z`dc4RmCOfmuuxIoVxVFFy|B!23xH;LkKn<L{~|eU#!Ph>4)g6fSx;rv@%wY3v+zX4 zPu~1j+g#Cl1+HIKF>4y^-%@YOkM{ihKDH}8$W;knQ3M$$lnahZT0LZyEdBaEHBwpm zL^*d#O2={S*`R|%g|_lcjLum2PqVJdyLonRNHtM%zugk9auY2JHwnwqePQzK`1fx! zgu1Ue#~N!c4&^_vU8GDHL~+`3u*%kZ)>cH+i7a+}8<=f;sZ`OZXMRwxJ@e{|Oy%># ziz#Z;$aRSt5&ZhUo(@GZkeJ8~1<n3)T_V!{o>E-&1CtJ=P+lm3*y#-bBuSQF2gLr& zM$Z?FOBgHM*ByOE!bCtq_`66xB`6T|sn>2O!;doHB-behBeJk{HBK?J#U~IMNAU5i zXt#g<>P;VRNCsL^Bg1!{Y-043_{TmgC$1Buw*xLTXvGoG&6i|Gcw;Xo+uK9&gMz*% zf{1mDD87s7gs(S$|Mi>K@ULU2n0hZvC!%CVZ3(3!BMNlP6mA38#gyfhe$2e%z(6=) zRVO3NgU}pIFTd@AH9arGMt1Hmk9$-e$^Tiz+%?7GaySz1nXBlXgK7Ovpq9-JL)lry zg=lHET=#`og{Xd?{;hs$pSQz(;g`ApyzalruE_+*5GK@+FYv`RzE?t1FW`xUBVv8g z9U3#^6DRY;b6`nzSGLK83~apw3we5)s)FPubO_Y;_rCW(OaJUGOhQixCoIuNJu4&D z*~K4VTpd10I<G7)3}L$d6t+i3G9{fTY=feBo*h+%T^x{K7k4x~#vqD@@(!0$Q+yO9 zP^EWqzLb!;QT>AN+;fv%KfM|+YTtU_p~w1U%(lYzRJZqoma!%?ZP_=?u0IhPMC_v! zGpQ461oO6ZGuJjn(WlDKO19K{%~sOC&U(j`RXBu@n%^noK)biwf}i3<Xi7J!u}sL- zkBpFd!VH@~q?qx7kXM2)+J3#Sq;+%<yj%%QP(EnpGuGpzi%2KiW-W2}e(>%4^S(F@ zvaPWjy$(WG$X*8nqIJo9HF+@wHO7yB-oR7I3>2uCU9ISDhaQwyP;H0?&;Jn!NH%vN zsz^Q-SL$9`;$-}t1fpq=^hnXxPra|j%(8VXCJe&lTSBGqMHRI~iKjvw)i0<bGahF0 zqzoc)T-Ti%%mNEP>&+lT1x%LJIe=Z=Aj@W(j)jC#go1W@F{Npl5`5KSA;&012wJ?b zmYmVKnriuJ?6)^YHV^TB8|cPAV+>iDc!jL!u79kHe>D(nf|!y?eb8n6*emTVgil^v zNqz~_w?0kYwMe?5><f8W->P@ZP)Q{iW@v<r7e$-5>KvY>*p-YE(34J<pwS~eS>RI7 z)?l+`@-jj5$kEYQpF<D)G%rVKV3vMXqEQ@Inb11zU}z{`*d1-FudiCwz<;2oUMXt! z?(IpKZ}v}4fqGy6IN8xk&V(#|_KtV2)zpeH)rFOX#dn^+5DN`}jiE}R@Eeg!;mWQ0 zA+kyyR!VamnOOn(t?J-f-mKgcI-U}s6h(=Ju0CRT4R8@r^Jv3qSQsKrO?5tVc(m?_ z%fgA4Ev~6@7@K-!@y~z7D{5%GT9V`VX2ls?X5_L_@)PmB=;sI#qcYaHYr?OJm>K6e z>l(3wg-e7Cu~A`0>Y6jj5#i}J1gL>dRXaiudFL$Yk}Mh$67D8n!&7Io;vOS}e`x&z z5_ox=hS4Nz2F_`F$Ip^70^|1WjP&ifRLio?>+t^4<37!~Mlk1KQq<@oGG`^M8K#rz zlzF_+8+K#HJ0kp(Jj#+^QoqkJA`9%g5W*$|-&*I@EAQGZwf(`1Fv$i{)L(v&Lzy*3 z=LKc4a_UL8)Rrv?xIZ(j*PCr#KRhk`O$`P7;n4TR#FM#(aVIW&ZMwnzfH}km$im5n z$?pvUEiCNSMY@ejBg5|Y;9xbI=KIDvLFtnH9>)tl4Lc$g&diMSO0(Quo_pP_;<$*7 z82g6c!n!yemAj4g&Av;>00m{0^I4H5YZVDU8mh`ErC<Iw>n2a+Et1{75(cTO<f%UE zzvjRQNOvtCc3NO6cCP%r>%OeYF;rn-++SGRcKw>$_>m?*C=yMp{*td`0*RtVGdHN# z%`QSo<km>I^w+n3e$8XLT$hyI!RkDbtsPlSX5PL6i7Y+kg@(mgZ1Of%u2x?hRS3g> z4r!Zby$jl9Hx1$5Fn!VZPi)f&K}3-tyE7;MrXViAGPf$j{f+exN=e9-w(d7ebGA2u z9^3Kt{A;;UXGvpWZMTiD6EA4LzK(+rI@?Q$h|g`}EA)^T*=3!|DoS#bxBn@?%VcOO zl{i3+5#y?o&o4bQn4p6z+S4yg!mz=!g}M#D|3A#G;l{h?rr<h)I30&3@~9Z*R;BC7 zxQNAc2m)zCi3~T{QZsXtnx(*kkRUV^4$`AfE@+aq2AHZR5gZ~WU%Mz--^X@!vF*I= zf$h!}P03(-K}lMcqrYy_946=w*ErQipE@WDk*!p770!rRAKz=LEHamaFFscjvNA5- zswxuXnq6%kqJo?0C?DMPJsBb<qMBpkBF9%M%(JIw>N}?VHI}XiP{y{NKnp`E`D{ZO z<-gVj{z2?>uSKF{XheMF`YiS%emC}~_MqL@E&%WkwMJIuxw!F-28s!Lg1bRxNQJ9- zC|#lDCRTTnQZ}4+cZO|VH{EFj$?0L?boeE`zpH0b17@<<dmM~aaLY^qas<-_GKI~W zQZm6brt>`K=j6Y00d+gpD_xP5a(>3qVdL?uSb-B&;?YidCWT}@4kDlAt9UO>%5BmZ z2&2S=ZLyzU;+WEYTu=R4j<c0|91aC<8hnTVrJEj?uV|-lwxYn-=6#bXz&w6eGRvZp z_j%i&kN@=qeu#jj!_0oKSHE`-gpC~;!Aj2rcaET4L5@$T94Akd2L%Nq!~Pr1NOTk7 zL;)JsfrfM;e$|D~`;g@FTU#oW{+@wa*UH<glW?g+xwYXlY{d{-*3AlCBHvM@mKt6C zFC3J43g<$_bOzG@u?%WI?#X*FBf>;ekW87XfBHZ{#l*j83i3gNWDaohW|Ty0Z;BP1 zV_~`*>!WW4330`vy{GerkIK>*2KVxA^=x0t<j^zfM*RgCcLV_0ZrhW=v|*D-b=4Rg zSB0m0reU|<{mwZoG*G?;74v=3nJ+P5x4`+dOUVdI&}$TSt+Qh&hssHhmY2~p3b4=Z z(_tbI$rF5s3oPlKuAceVPMG!`8kOYxA)0X!@rBqS58n~TBiPWbpwUCB=jpjkK9Lx{ z&nmg1&z^O5TkV|u#d-R6`R~wV{V$7Jth@vpVn2xQw4Pvocv@Rs9PLHigEjO63SyQV z^d+E29s^p;eP+1GK#$nLG#5ADs5WAhp)z*Er>`l9Q8Oal;tx1scsi~L__Xg(f)mtW z_q7sF!BLXv7id(G?=y)mL<BJ6gkk>cD;OGvc<f79pe*yR6P-N3Qf%)3K6pKU{$hiZ zhDH)PH@p;$(J{R@c{ZSDi?+V!FC-?~$SEo1z*8{GL^gri<w{X&&co2$L=fG_5Chxr z1&g8n{{MJ>mdIbt;%cQM?KODw?RVa9QI$3{(O|#;IP_CfTi@I)7>mVv@CPH&G-Sk> z;Da4q2549tPw5bzS4QaQ^lwp7DPysE9|3>MOVZcZ_oH|YAb}OfJ^eVF$UgY`I@)-# z2jzPa=t(r}BSI$-Bk^eukBt@Nq=bIa20`q5&>&-@tj&no@r&TD?2-anCc6q6F@i@9 z3-_L%p9kVBD#Z#<`2~IAr^EPm^tu8><JxfTYlyeVGnhMZ35%2hKITL!c8Wd|swjLC zP~XP?RVw-<*{1(_>K%~RVr6B8%)Na1(%0Wl@x-3bNT_TS1#Flexu89Dqv$L#)v~8= zV8X)&kl4Qa_zW0SM)43w3PZ2s$knD?%NE$<4fXX^DpB^1Fa&=7LipPZso*Zg2Mn#$ zA9C)t(eA@yJtZ{J2{({Jg7LBqYPa5EC(J5fYX*Ci7rBD`vlvnDc^R_qTIFKq#5L;R zr$g{oWZF*wpAkKJxL!{K`Rp?^tyiQlJR6XVyk1cP=<s|A7>=NY{s9B?U#P_OKg$~Z zA1J`Rk%ECKWpg?E@icat1<I8%CnV$lkzDX9!2o;Aa~MxZWMb~qfDyfcX8gW^==t>c zFIg=7zciBnUzO3VpY!t^e)MH?`)Hyq&`};le+Ca@6&fyw52Yg#G0_go)k-9L!93m| z;$8Omn$I~R)axy0`@rS*MkgdNJI^9ud}K`1jO+}FYSA|^Xa-fy8P=KF(B^dc!Wd|V z>B}WF5J;{oQ-W@~QpMVm6Ms;qT6$EvnH$xIWEts-5zFqYRI^oCvZQ(9fvVS_Bj7El z)|$t9Pnb?Bcz6*B%HE!VoUu$pt?gw>)VLgmQmZio2pQnVB11#0I}nV)3wxwI9!_^X zXll03^e@n%r)TJrywZ8}esMrhtgzeuo6mWFq1JN`G1JG#Cz_`DelY;lKN1*C?7GZZ z3Jcz1V2qSXmbOk>*6A(7Y)H1;YA;r7c!E^?WAhbWm@p^-CZ7%HVbtx0&S5g;9bzsu z$Oi+ixPDgd<=;tpXwcY#rag}Wd2G}EK)4SgB`M;xXZA0k<v39Mp;s%lSO`UTFq+Dd zH!|vO_X$9|znBAMLKgFxlSx-RuJh>*h&5?y&p;m#au}#olAEaC30gUk@yPkqDs|yj z9=%8SJ4%>dt5Rj53l8qpg6Ad9)8K|dYq3t6T3X9Ewe#Z@`nFUZvz3mi<fTgwN(I@9 zpy|Wy?}IB4=#?*4aZ^%?E4o``w>%sx%oSs^S^v9PJoi+XNy>eyh^Ff+t&w<?o6m&? zjd$GaUuK`+qUyES?;#r4rri?ywA0>Id^VlBkB*P7Hv5wbq{t^bhvLrFM~`*ScPDX} z?S9<enNB{OZ|zLbTMgmV)+<iU);agxAYvioaN+OfM@wZ&?9aZqy?ld=JeDJo%(csR z(gXT2F}Oel8xAil);pMHXS5s?2$=|Tv&?21%r8L=`f@)kBm~GEmp6gR^5(-fS$_VK zL^Ab@SOo01=qQy}wcIs_)5bluz;1E*WQ8{~GsB#DZghh}p;Va!Pa)IgoHzsv5tp&8 zIrBz%No=sY-)yAm+N-6xnhvG)ikyU|)|WH)pkW{F5_p!+5*RP9bOgM4Q!JjyXtG#j zysJ<Q3c8#z6)NVQpsHnap0K_?e)OZvKs*5fb6PSvq(d;2TD@j<c%_3xlDO;7pNWF# z;YLBwKW6IOACnw;+BLcXimwvlqQz)<G;;*5{mlVRGgyG1$mXzDXQS|Y?82?N!5+x- zR5(6*<89G&_xF!(CNUXW7%2&P(Jhy-TSyN+{33SWHBy;wl}wGHN~k|91P)T?e<#h4 zEn5$#>qTp5iP>_TPl<V95g8K)a1$L@^-e(DuV#PRgK}Hx9FythY-h2?e)swf+QDKi z3pKIBSv7&0ZLjyO`*wwT<0UaJo1T=v3X>_b@o1f7DK0Sz3zPu2%miIq6d#7l2;n2J zzrs@QS$z$`oCqHLRNoOYrRm7Ho%d1(Vr_&taDn%w?6IvqBV>^9<7|t2u1(hG+)kmh zPcvPRrAMoQN&*7guE7P<$&%Ln7%WU?qffe^&CBZVu(o-D9`!6{0+Yp_%6j)mmgC~1 zlxZLF=;#<xr^-=ZhA8ZXqvSAUjGOsPD7qT0dncu`2v#Zt(%`;zV3^`&w^OAI269|L zgt5|fXpuJ{blziE3Dg)uqUh*t<@B;Z(Z=C0Vpu$yrzP3cX(GWs5^yfS?5y_eDHJ$2 zLFTqfr6?HdPr6V3O*adzMCm!0PTt$E#f=o0DU@ii>(BlK&_Gs`kU|uxt6;*k6fDUD z3r?90Wy-^_+uzl?z?TyA%F#Y<`Aw<0^B<;gRz-A$;Y?k`b-I6{py<0F@(B#=ohfa- zFa)JU=4#mhq!MR`{TTw+Y8AD+aSVpoQYB}TQ~FB187kFU?&EHs1MMXs)VSnw`9d*x zWW4GO=zk$$a2)-O(5={1VL|~n4gKz}uapqmE+m4{b_^|$k7eGYPaIUep~2t?78-#* zhav2y-yY<v)=JEvOWMJr{NkiP8m{1HP^3z%thSx9plSxJJ;|j-P9mhaX2-)Q<4PdR zV>tNmP#%C6E}U6X->X9w$6-I)vmV7}Z=%WS7WcCV4KKI&>-%hE?gzQiLO=^&<ZNWI zoLs*Oj4YfgnyYaS&{G0r02Km3$I8}JRff+io8>Go69+s?H14NvLI4;mZ9NA*HnVBo z_o|Gh2{9?heZTkxBSXy>8{}6Zlf|NLp^JbRZB#uw-vv55-LEI~3MXdEd0iEY@vjq> z#8P(V6$f1Hb}`Y>YYmrLQa2yn_O1etQHU2k57lkrs#q28>P19L3Fmf3>t#VD&hc=e z)@B2<xb%zx^4suad>evSZ8T!`J6iGgpHA#AaaU`1uONs0arf#0Z)OAh{YedpN&_|B zh_<86R>0oIWI4-s(H0~hs!pdn={PbX4LSy+XMi-)WP|-K*t!6vf%y9L!Sw)gNxOxK z$(%>yc`(y>*>%m`m#V1F+T<{xqVJ5hkzl|36L#&WkGfd1bg^Wi5zuL<R-%5(x6-IC zmO9dy;|f=ErO-;Z1o!CeO;(#0Dh!;SoPf5Eld(HZ{Do280m#xrxTPU8oCf>q4&deX z$w2oZX5E@~>tN5v`;$I@a&X(*u%JhQ+Ifbe%dQYS>j^7if!;c^&TVELK;D<*0^Lal z&S6168+5MpXX8NV2w1|daae!_m^?8N{r&s-w3fAFht0H4&d7+bBYyO{BQ6cyN{><t zvspCi7hQbfh;P*mc(ds4Ilp)hNBC-YK3`YKDDM`3L|XcC-q}?uoIu?KlyY}_4spc9 zqCSbqd{p!w&C~YT(sWV=-7y?WB3>Jd%Mz?}Ga<Kn-?J3ohpV&fs7zgzwpjD2fDXaB z)0)I;7Xf>dsRG$TuoZexVv$*^0#3;&S}L7uC;}g!0Eg9~y0o-(D3#gF>aRDmt>*`~ zx!f65QAWEp<`9jZHYP=NvUhPcek@B)9Yb$CqoYZH37<dPYM*@Jl-WX!`o+<JZdW(b zRE+sd#UGOER8a#K^G<B4HYh8n35fG$Bn|K{T)Oc&)!oHAHeBm9v)OYv@CyU#O8{=2 zBD+iltT5H%Avi^xoH*>wZ_Hl5U+Gy1_bx1LYEle4nC%_}s-gjcb&8W?X7lS;B#sQ` z3;)gqjF*V-1~+_pkPq<nJ=<HPA|?I1+|K*?Yl}NQF}Qbk@b|JXUPHzh3c+XtHa51z z?hO1V!SvL?V!jx$&V<7`b}}C{PHX~dks4?#F``{9=qqipsmoF{RO|455sI|QZr>!; z$HxdxE~BIyKYib$AUjizrn|dRHOmaD<rqiG4v@2}1ATUtW9xDi{nWKC$vpZ*_QPBN z<X*zTi6(5`{L?cfGd8NPrPb<jP`r*QDx}bwVq>5;`|+Bx$5BsfI=o%QB|D<3@NnBI z*KV^-7Oz2nZpnQI5ZC$!qttf_+-isFYj?*>_C-pgY!%P;$3*9}(%mv0id7B-!O^rx zo3|^KB$-N{2@aVRN%>pIPRW^w?Zk%;HP;EE;p=E*V%)**Zo81Z(fmxerLQk;q1p&- zqEJ8*{dM(q;e>%=C_G1fZX+67CTPJh{{%oOHM4^A+^hXL>TCD>2@`iv|4(GJ7|&3y z1acoD6|$WNwUxFuRn!e86K8wrO$NX6$#vn7iCxY%HSSZBonNtG-Sj*J{=5-9tr`wr z{`nK(b-Ub5(Nf*<mS1ac8=`K>gbBAXQj)|la9FWfmdd!rpB?**oRYFK404@3AiQuY zXw+Jm%fnXe0^SGMuM7tsJZ4U~0Sj#=Pud57gSya6fm^`h2-qCZT;}3pr|7C4*q?vT zNJBmw>!#<MnFvPBGZGG&jk)x%1TALB`vzwYnaVXk93*05TyE>tM-8WX`93r#3Yo35 z9(g+YlmCDwC`!u8jxr@l4rZ&_yUkiQ3n*v~X0w03F<JTk1p-Lj!nzygh**qur;~{W z{SO+?e=N9O^op`3<_eQr&t%8GgiB3_jVUC!*qPv@`0?shd}c-li2lf_5`Jn3{uKr? z`e0w5o?80iQV+bJQzst=H;z^aX1xlxY=-p1Qf7Z;>6VnP&1iar>`cgG2uYm}-(R|M zIv4EqK$&KyBdbfGT0L8B%xTw6q{4E?oVee;?G6oRBGXxfQw&7S6tr!X^DG}9-YJOm z7fjqndcyLfJKp=)Z7<cv4w$IdIR#QyYp)SI_Xu6*0EZVU-D)N<9nvVDLR{6ENmpnx zWP9Fq3MT}Une?{8ekZA*$7cO#1hN_N8;}gh4SD}~ijiWXd_czr*!QGff$H*ryUhId z0tzH&n^_nrOnVMJ_vSp8SKtm_|3g|F_aiuP1eSi3A=pAb=JeVND9ZJA0UIo0u{-=B z9neJ=e`|}(L^qOKK)+zu5tp{?S>;tZw<l10MNCYtf+Zv>=~&va@i8(cy_cAaOM<=# zg(Q;2e6h*#&>N@Gp?9-bwN-oSfD@V1s<dd*h*5x_KPM+gxYzcN)ezTUwRpkBkYuSC zab&r{z)RtKBs{9k)cN#|fZ$N<FKL!bT%4e3q}=q0n1;<}rvzz?uWXf+kifB?*!a&K zKRqZ*F+*da0n(b4lU=%0XMB|QjE-)Ef6Mu{85s~`q?%Q#J-JVP+pj;d0eBR&%Sm3m zo)QhtF{Sn)Q86jZx|^2(NO4#lEiby?CNi04h@fF%fw~p7_Y@#G;zmj&aTr&#=~UmG z?~rQ(Vx*=!!}atrN`<XO!wMU3gy3-x*Ep<>jt-#dOZ67)Bw1WJ0CXtmwNYTIATY7I zc5}YHP#TswcJ@cq<8dYTx*~S6NSy^t5&CM61z<$^hmXJh(mNZ^rkk*h&FU!pPWMOX z&26fh&T2Y}g}Q$Im`S{Gb*EkpQ2t}yo<7d*Q&i*HlNnNjCd)|C*#LK2GhfRkdwX$T zXJc=`eKl6oI3m1}$@FpbM)Po@GLfP&KR*1^VGkJ@Nu=yNi@|Lu4sP%xm}j%C%ypNQ z)ZTx>MPZFPwk;NHOerLVg^!hZ)eX6-fhfl%SJNNMR+<S|HykahE9a5U0aZet`umvj zKQ%6*LV4N_4wXdL5nWjzno?_)%eaQ7*l_I7wHw)?1_PDC#M_d=o}PQ%8G3nGC=|V~ zx1V#E`p9|~i?D--x%2l01?QR^6SaBj9=p}@I`?T>QIXjUuCs8{24?=gbI<m?V1uMg zs4mE<=ZfztoTS9YvUvQ8Ejd^ZBPMdEU$G5iC5nzbq@cMpF9c}s(n3A@G*!((q6T`7 zZ}NUhXHslOe;h6iLlfGc^VItF8}J3OtJwjpbel{%Y`xmSibuu9c9Y&3Ea6u}M9v*b zpo$3UPl6=eTxVI%pHdE_7Akl?Y$zVO(H^vIlN<|Mo!*Xa^v*;T$@guRu<B;wP(9N} zq<Byx8u6bn8SA{<pW*$M3Ib}+6cVmj)DVPlH`XusQE5|2IN)MmN7rrI*9vI&s&M}$ zC~26NAM5oo)8O73yVr~KP&pFKsnpIid$bgdb!YT|^4&<1OSsTJew;4e<)<$L$D`7g zWPDA*i_Q{GR}v4pZN1k_VH^O<k>1(@IxJ9Q{@nNttKQ^XC9qAeZK7BnunzGF2&iK2 zkh!nVv~)6<x60Q@Iwj*s=S~;qodLn7#J=-)lF9SyzKlQbN$N>Vp^IGb{(7qy9O}>p zSo#EOPTz06c&R^^a^H%s;jupeAkB65WAi&BB(upgJe=}ZaIeG@=|o#9_ImpJDb2GG ziqvbrvhKFjD=tHC0r_{3z+QG=1p8v8^(4>%J2}33E!-6fbW8X(3@S6l6+Aq=nR(Am zCM+7SJHx|U+0_mK7n!K_{`0BjOog}xqz@Gp)z{C@zO`<*;)7>H=^f<R{pH?bqG5T{ z!=x)%<HzOY6;Wu%2-w3k{Zs<Rb2-s&RS0ej`Uy?I{+qr#-nz<c#;yOaY3q%_$pNP; zpkptDhlkJ4tC_!h&EC0am3)$2N-Q}xnwmdm*wd%G<C*HJU~1<)lkGD$I`#+}85bqr ztP{nnxCr>8T{2oO7HKXmmhcn)!)^nd(A{sRita@<-h0fLF+1b2uJ#%;7!)#`^LSc= zwx^iVFV^(Vy;bo`1NY?;#R+8j#yx{3rYB!Za_M>%7Hqf74?SoPeg{UDHg8WG+e9%i z;K)ydK$uxtlGb{=V=|Lb-@_>RzIG{=I#LmUql3m>By*X8gx3JaK*&XUFM3Tadn*oG zt_x0JXLL_2od#ylw=uR=Voy&juWoirk}ub|aT7@&t6-6LxNR>k1w)BB?+>>?!NM4c zu@9+#Xb3Q%cwv!2h7raq7Mjhz6fV%IRG~=GFec1$T5rEazdPwW;Z_voSPv@s)zP6+ zYXUBjVt$O`b9<!QK~`z&g5z@9@3#;QAR=md)?Ek)mK)^;Gc~!XZgLr^oUWH3(D(G| z(f9UuP~#L;6WB%=5wo*zh3WUnj*YI*8H?{TqT3&Qe5<8Wxmn72d$GB`j)~ykV86p? zK5BH7=AC`{Ow$@oG=+J&;6M&CLQwh|8CkPjM<3{5h!kYDtMlpcJ+^?Z=D(XQ5%g|z z1G>3Dg~elyxa(`x43rvv9v#~iRg4?Icb$vtX$R=c0O5cp1k>2l{7|5Oy3nA(SmvpD z`B1+zUSfZlT<gj0nDWw7Wt~J;wZ@FjteXhPY+Gv@4*2@2TH@ZW-KB=%ibIA6nx~v+ zX4x&8okMMjAQ<&{QSgwE`jZpw6Uf=lRS(L<YT-nQylnkN<sAC<T%#gL?HRbU0R5^= zmGKYQr~ssmnU=K3xbEPCfnWaM&yaHVqRp9@ghlciXt*vOqmYQ0Oet`^m%(OrVPqwe z*U53Q-|0-*e&z|$AR@vE{_#&(P!PGhL~(F7XdVS*>T0fKQ9$|9W8lDNm=b}6;sl2V zQ9Bgi3c(O+GLW#45VHw5h?3d%u80Z?NB2|E(|?N&eyL<^Yzz)9aB#6TEEScN`jO9H zyjWY+5goeKyX;lynGJG~D>W}G$~x0k6tHn4I*alyE29k)eso>i=M6v-O<*+`7YY-u zu-w3N)ACviMs4&1SM#OTcW19C1?G*{Qa+%PeGxRDuG$_OYKijhqrqac;?XqE@=XWK z?a^7|MVHeMoY`7g=(|d{soFALe+GnM)kbD>lL@geHjLYic$@e2F7_ZdM1|HDn#@iW z;}$;)#%_{Il@z=w%?1z?yq3~o(WDxHC-BVD5R{pGD-_u?-Dns_;jg**XRrqPI#tHc zFTKbC=`@O8K)_(MeC<S2i$`l8WP2vXj_gbCz-xw~TCaIJAlm~TQf$qyl&YZ73q1bM zw$c0-%Zx(@;D2w=5Ojp8Kd=GvY~p|c=K!_mwE2#kA!E0vz?iYXmX%i|8y*g;jb2|& zv0!NLBd)jzn*$};cDFQ!5U|Hl*a|&5DoeosoG$R~`(0R*$wbjagMFb&wF#Nr&vuJi z3Ya4j)_P|sN@R;kO-<d>t41Tgi_B7LmjGPc<Tb(|wc3MHO*Cp?TXPJ&7$&X@<qH*F zz@A}!N%IUq!XXTn+xq_BiM2@3bh{Uqae6cn&5(RMIcfL1v5yNM5nutHyz|Lyh+mLi z>F3Yj4Bi-@DH_p2fal^*HuZ@6Z|%KjR8xEOEsA;o6%`9bx&=hKf*?o<h=3FU=^d3W zO+#-1j|C~x1JY4II!KdF0F^3TKspMc*H8n2ycNzl_dmuP?|r!6?uWa-IMnQ&oxOi$ ztvTnK>-Ne_GCjXZHM^LYK0dO%yu8+D?-T>FC9M7>x)x{wY1%_xqAMOqzaKwT@@mmj zg3~FQEy3TkPZHU6*YeXk^O<!wu4zi&yLa#Y{kJ`mgF9eJU9+{j;I+N1d64%o^^Ka; z+Kxrc>Aocd@_)wh?r&D(*mfD{ULqqSGo@p3IcA+Mh(lal95Mbe;iitcd0LXK8J}V8 z9|RG<GTm-SqYB+ZMupT6gW#B+j)1wK{vA>dGO`WnhM3^s;D&~y3}=oXk6M-c$n&~* zrk3;AvG#N4&Up?;#*5fT&pyQmB4`79bF4x$l2Jv_&_jvT)i)*Z+#~PuJpfbx=_v>- zfmQ`LRaRFUxu^>J_KIy++Fic<0W`u&l<ZFK>v?74o(n9mg*+C!6<AR_GW%Kxb`q6% zC>d9OZ6{)=q@*MxQ%fRBsi~=`G*^=r7Z-DLdE?s|FItDZdzUvvIc8yQKIS%ljEbI5 z<9hs&{XO5+dN05Sl}M!~q#GjL1iGQ20dRLdlYpS0)B+yUu(#^2$j6Ug+<R37R$oW8 zw13rjb+(s{R9bbKye{r8f1c`y*{^g+ypx>`YgKkDFNu30n?Uf=wY6N}WUmp7fB9!} zQnA4MoAs^MWppBM)gAnl;^0zt&wiovU{~9la1->)#S&mWTF+?haKdYX!><!NHI<ge zK8%)RXTQbIuUa3&&Kw2j4q2A9IK(6taCa-Pg|bO{sz62X-HNnNpIXll%n2DM9jxyj zuUqMQbr$Algg!4_z(aFz=ec`bD_SKWyh!kL>)8uThGL=DjEdY_-ZM3x7uHHh3p4e> z%V7?Dv}r$3|F6Xk-O|0%*AgjG#mCDlAt<Qv0SOP!+{%jKbYuCByXm%nsa5BgvcHM^ zD34=6aPZFK<?RP?w{)~$NRdVc8QH<z1$Cnyo?@}L8`(<D4%8=4M$2BJY{<~o6BHC| z8gmw!L({kL*WjZK!2p8ha5#~X)q=UnlVW{%%w}e0wAuGnRDxS*CT4pjVTQ=d$kZpg zjYD;Y6nViAxW7tnj|s?p#(Dj-Hj^dahdxW!o84}i1O-jTzSO?I&})3=6>|s_4P%dW z9xPOhM-x6x91HR_CJ@$%+Ne-S0pOVM@i<dKVeu9{`>nauidHCA<z`_aQWWXrG*x%K zz~GDbcj(xXIw5pHD*n2-KK?f99_cI)qW@kj=j4@;xN$>WhVqAHG4mOh-7fcTFtz+J zVde<G3+MRnjR^p0RcJu<PF}NI=;jO6=HE9MgOS!#WLyGxn*#!@D?B^D0uj3W2QGy; zQr!gUaD+q>fMLP2V3R(eTY1I*s>GGM6eY)|XdSD6uL!^7ZdWd-&L&(uB>Kqe;$fS% z7s+X%Im^hHogxG?d+5Rm0N}M=x5uvUgNHjU_jg#vEc|(VNwtx1tTI-OCM~JRg9$o~ zGZ(zyG3<?&dJWn~r3S=p1SbE27T&~XQy4@v0*BKAa;9sXoK2Qx<P>qB34n7%MfJ{v zF@HL_^=h;I_BgAGidN=5C=E=wY4!=H{O`{w06D*AM@Iv#vx>O%P(61UFi+RDD!(g7 zU&q}0_eRcCjhcidJE~J;o6aRLUlLP6er_ENyn#UYuKnRSiFRm-80Y@Awa!eNLb`9V z)i>4E5>-Xe!b!K|D<xQ)o?(;XP1dgQ+ywl~WlSG(oLv3l2lBmb0+^I)C0B!#!GvVT zb5NWxzsP4;$t%=LHCmS}Tj^S3J@qyxPh??IO(HK_RbGA?&}-nGG6|<Z^-Vg)J_swW zyKLr^Y-~KAlv*B3)qVNqL8Z%z%k#3Nck#E_B&Leil2xyRzGQ~fKh|fn4@S!RJKPbz zGHy4X21w^M(SHgR*v3kpj~1o|XeT`saqQoSHgu@8S$vnB-EO*Cj3X_kq+{i?Xm1#< zZ<Z^S);E9A^?4}0si((y?f&xVED?v19T>1R%asd}k&(2m(&8%7I7&U`s1-%)&52{% z19_SX8r>gc{jZC*Cu5}XBqI(u{OI*~>EJp2^;ewAH7pYqJwGF?76zV`^^eL4xX0sQ zev7lf5UVFL228zNRykstPbtUnu?bEU@fm)(o)FM-xz4f-J2R70Tw)6ljn@|Ucw`s% zl|Mg^9g#N`*Qi%?tz|xXD6}Dcn@NiBICM(Ustu2h-fB(_RUSIe$au#Ccbo0q-e*Tl z?P<na*1exL-``B-R3&IV$%S4Nvi{WLuEU8DpSThNTtA;YdGe>e0aR&b(+s?;)ET2@ zmbktG#*M1?Ge1md+4xH%{#_zWV1!~vtvC8Crims#``|4mxO%X+vN1oHI(zE8ndJHp z&DK~q@BQ|&lU5)5(8Ysx^Sp_o&+p=szG<H~XsLT}d;E*j4FBLz_&~7wv+M-f6rTU) zX&3S=TPYhaSa}Lrg`B$YENs3y5n)+{`r#t{RFr#;fLj+}jXW@_m=9NAt8$)QwQM)s zTI`!y_FCSDrB{xJ&1ZkJ?5aYD=j?9<3wMCuiTf*5vivO9f}lOv&KhTu1ZvT}Fqvn3 zzbP5T==Aj$zcIf<PswXu+|S<5gBy+}io+f|tLOBcFbButMQjJZ82dl|TCjucNrS#w z+7+!Vk5~{U40A1|R|~==oL7K#fRfbR;8;ORtrL02p|S=OFQjQAS?Lyu!1@eDIhNTO zB)dN%xn%FoUZsW**B!v`ujE4{c7o#N??$FZ!p7u?546}DtI(!k=i+I@Och-7;kPZ{ z%NGw!<R<ErhGsrYt;Bt475l65d<yABlV#pWrqFAP;p=Q}j(Slq%g<%;1zx7UPzI@C zh}}UIhZzcWpe#^>n|sx1WCku8%Y((+RV&j6m9qnC2$q#32WC<CN8lU>yG1lZeubsh z)2G=YsGQzvHWXnxUXcZw4DF0$BzZR0bPP~rC`Q&Y3O}79ns`7SUDa7_*3{8fP_D6e z=L*CtVqzXkLoTFB)TJ`hz5LUb4-H(z^5#eM#ZZ6Bagu!bJs)!zgsc#IKQy5?lHGXz z(wlej64OxvJyp7D3_E|?6p5Z&b4{#1zl5{w<3(_lxYjmGW5J)2SHOw0kM0B&b^NV4 zr|mmFYin!tZZ(+-q07_~JJ03}--EbgvFO7)Mq$G2<eFSY2!oJ;)DvhA(G=q0>!#gt zxr-Jmxk-<!TAJ#!_s>_Wo!U7iFQ6S!w3HR}L#cG0^R6eSFK<+d71UNP0Eg+_ddijk zFH-|-(;XhOA1HxZJ<zLMtp9U25FDy+nmL4N*KA>Q>G%j9TZ^%7qaIcDj{sfu-JSFG z(9-u@$a4euSDJVLbn3hS=7%p9O~1Fbz_YFvE4Z{=wc?bl-%!frSgqKTLvfl>0{?BY zh{F_hbM1?v<B8+P^<a~ORWP0VAwK@>ZtZU9U)rz+sE5oxjjo1fMUJDD81qL_#k#o> z!3>g0Jv;%IlHGB!%aXez&u1istUA67FWDQEv2NFUedot@7&>mvjy-r%G`vz?rO;Wc zCb_?eqxEgwVRF29wMh>B6gS0%GXd3%YT_MFm584jIozTiI4$fkP7<jBhqRbAt%tYP zG3AAQr4sA0MZ0LHG$B*fUG7`AoG0oICZ!0{cRB0{ujx%LUbwLC3vNK^ZPB!JbV{hH zS{O@D;qiV_#f$LrsxUb{jOjRcF1!G<Hr)utjf&67>6~T`RvY6TTk2-J2Ws(+3q#HQ zc?R!l)*6YmtACn6C<9wEEOxNy-fvWnK(6zZaj@Qr3Jbg8)c7+_*!Jl<HDJ#V;`^y3 z39}_Oz2YXne!pSL*G1n|W@_tw)wRuXgXA?jb{n`Z&ygf^6?*tU+&SHSAVzgH@(Pa} z+Z8a_Sa=(rYN6YfZ1}|?QN*Vaup(C9#c-bdi>R68G9N+I-%VF=)RV92O;PLcViC6; z2L%rxRgM=bwhCP)dSSmqf|@)2xiIaW(b`PwKINHPnWn_RHva<s;(i|uBFYD<-^|4` zI^U;zPl)>bGZ59!xSICY-FZH8?onTE>{%8TA04XJEnb72^=0E>1mpGxo|>LhjH1$F zhO72NC0Vn76Fm;ZbUo86<~}i%z5s%tQeHJ-rvmvC+<9@z0-m|Q1De>tOUSn=gg#%d z_AXPg=O$wwV>cyR^^8)%t*Z(qLf$(Tf}gFspJ?lslyv7~R7W_}n4fQ=1#f&EM5zne zZZWqEL1avMX4bUl@1_tI%;r{A$wn&{t83E7t!?Z%7xpoTu)FXCZX#T*0^2VRrkdtL z6nBW>QBPW<Y;e%m=c`1cvdOd&K4P-Tr>8$ub>wi)U8fFpZ?3{ovDD;wu`AKiuv|zz z&CVh|eN!<aNW^}~JvcZZARr_#;su};wq`v-f;vDwAKTyEJbm&c?ma?O)V(LIa|VT& z*=1_9Hbd!`tBZLtuNb9$kDdM1eG_T++$IhK?tjUDX-CMmCkPJ<YcymbqWLvLbY5Lm zHh6}|(Vmy^2#(q$EH3`U7!Qlc(c^zNB?pE?x!=!u2zZ#Gb~cx0M$VPI<3tC9XdmYi z<^F{S#}9Aa<k1aAbl)}a?m%d2rz(~$wFDANxzvwE&24SNB{u7(3j=Hjl!R<V#*g4{ z-@bCR8^M<O1i=9@HJ@ZnmqF&f$42u*`xJ;OvrnC2mB>s`(;{p;b<!tRK6#z`QAa}~ z!tU|r{FtwU@k4`2nXiMoaXC79aXA+}O)p9gb$9o{RAm*b@fU_R35%mSGhy{H_am-T zew_Uq#wwrd>mm1mOB^;z63$S_-=8<Eq-Q?tcVxfw;-jT;jPLhk;M+XIy?7c>K6_wR z(9FNP)`V}pJ$3dvf>a#~$Li74ZtGHRHgY`xn0TxvikxQhzkKgY<$7&y0x|k{@27SM zsCZo|Lg}ose@J?yC!pjXPs*Qe6ZFMFz4xP@v;w7Ad+_r^GeCdaJ}ZESp-)$iw`>Df zK&E*LxGlgQ*z2PaRZNj!9;9pH@*SEU={rLwuWg#Dj?pSLd;4{|cFCi>x@?B5TZ_+g zvr#pkmoHzgsHn&`2W4$I*#fT3pEO%D-gOUAci@-30v@0pwf?QyOkrl<;JW_I8OsV~ zpw)Q*R#LN#K-#WPt`~91$Yrw`dcY~G2<=2DgT~3Q*z&Hq79>qk^^c&-R9d!QX7h1- zX~A$X0Z4v+0KSCw6UCj4^oiWhBceGH?G-k$I?Jf-9PRDLp2)sF-MDs3`*OE}r0*S0 zqnWlrr$0|k(24uz^-h8(fRVXI`_$CbA06y$n#whFk_nfU`sPqh!OuL+46`OG=mW}0 zJn%o&orDmT!w0KTzmWPbicc8{E#V1*+*h&V%8RyS9$gcs_O}|-2{)bk$7;NCw1Y7C zPw`Wo2wei#9Qsr>zR`BTWG}RQey32g#x|yEJ0G@V>toQ$5o0&oCHP|{Q<s*7CC?>H z3(Kv3tj(YHn|!jAO4;ah-R>-1ljry@Wq;@8kt~2B0FzjMKLkPclYuPqln6^`{+iQ# zB@Yw^Mk81Frh;1a;{L+|d|(*NtH79^Y2TZh&nbwqk0i@69hW*Ewv2f82!<KDb7$l1 z?1g`7$jPefGLA&QlB)6Cj(B0yYf007$p{`XvH5Cf&3cE|oDIX!d!P-jt@P#SV&=1X zBrQ!`cYpiI#h;CS=<Qui=akEPIs3hc{VrtG#Qo6pp=4u$W$qOKK`GBLtKU&yX4LER z(b>;<J}G}*mCMRg*sL-4V}YS#l@Je)q2)!%`Js<NZp%Lle@Wyk(>L9L#ucA@EZmF* z<~5uT{QUgRu<(byIL*}dGvlC4lR2@}w)4u*A!=0@*X3pLzWo9Ux_@&xsuJcjYv~C- zB5I2vfYyA!Z2b95VVaqo)&0(9_xV^WDk>`3yLTHyYGjxgXle88(F!V&@$q|`1Ex7A zt9Ive(8X12YK^}0H*NYC#Yo!|l|c1upcci(3LERPd)UV`JG!9nW(SbUu-<sK+8mzh zT}CY#RYeiK;(2!9NmfEaLPRA3G~Mygt5WdUQt1Fq;t(`6^(8LYgoekQu5nH)?oZlk zzE6F7%zYmwy`+5Y>iEpUKn~}zoIu00%<n-l>>MmPh8e15K~*N6b7x(4=U&xL`X`oO z_%9S%*v$sF61+Sx7Hr-VDD0xkUeWIuwIqo?^brnD&d&Yc{-}Kl!rC4iZYHm63>=VC zAIuMzm5F@48Y%_T><mNnE8EF8@2(cG7~6d<EOr@4EVErYV(PtSp_sC@GG4&U!p9Qc z?zKB-WnOMSECQi$uVn~;6V3$A^I!9JoUklzBQ<vTlS@4JS+~bw%kOC`up6x`1)rMQ zS#pdXq^JtX7W5|WA8_x&ud*oisv-EoPUWO|eCmA9u>#gRE=zhbuxlVTCXT5|;@zFB z5Km5=5O%13{G*(DJmxoWhpnvqm0utUP`ya(-ZYc9JuLx*!P=sb+NjR}iy$b+Harjq zX|2*9>uh?hn(IQ>Ye9YljS>6c_eBZdm(R}9{kvmVOC!&wc5A7Jt;`3HEU@})JXZ8I z@mX?Fe9P*yxYm^HUIc<surXPi%<wFR{Assyy>rxk@^ro&EIw{nbWCT`{wTyKSTwL& z;o+Bm-v<LmdQ%nXuN;*v&8F9??=$z3daTA3dWiPt`mCkLU}@>;XXa_D6(buV%&PM_ zfEA%;Z8wKBngpomTa`sb*+oV9B#q-eH??)8R_lDSHJTqsytKG=vF+R=Vf&#pm?I{> zXFvR<abB5A;hu1D)F7RJ$rUfXt@X@@1E2*R@rn56XVx@!=}2^SiATv=k-tJ{g^aD& z?C)JB$2h6YVd^p80f(}Tw6t<M{6fJdgxhbqi($8vd`7GpS~a%{x;q}8DBv%RXZSC` zmi==PYd4kdJq;0bvLMzl55XJo2$UjE4~+{3f+~OPqe8qq(-V&L`m<FNcg#Zjv$5`( zMz9s8=|WU*-T!0GYV+1tjb;U3{P%B$jum288rH&O_-Z09S#HeQ&g|dNEusj;LtB99 z)aPtpY$j(NMxGQ}&uN1<DB{3+q-v#hDRr^n`qB+@a>^X!+;B!HP6SxDq_{Z9dA_Ir zuq-v0@!qaEWhCYOe$jhdk^D9J)q<Mk&D&$G9UZQ64>82TjqDr+x}`PatB<WQ2Qfe5 zgcXkR+J{5!h+>Mov71k~Wm5XDN|*Tl$I-kTFS2my^)Ce;9yF;hcg&MbFry<Ez6z1V zZob!I?>~L2q^|x9S`^_5L1DACIK;Q|;qhbW#ADNww#JZ}Yz7?bHI_V6Ol{7K4{nO9 z&v#ZIpZLsp+W|T=`!wQVGI~s{p40Tpl6gr1I8rte+WhG8dfdEOSdVS}Oh>GA|8d$3 zEp;F1xoG|I)QI=Ol7Txr@gk#$9mvEhz)Mx}TOfZA#upRYo6#)YJDsPNAo?LMO~3b( zPP{f9SNah%u4$rZne9N(^bh>OCqoC#+FRRugD`Wnr05tmI-d0^0EBrME1U@+JJlNI zSomvcE3l-ECkWWT=7n6ktZ;Q%lU6GrrI<v~&kx)=wP~lwT3{HaYsc~UFH2S`$jig7 z%WYgVXlFIt(EX%PE6uQEd6YkTLy1C)Fl&ZY2013`*OBN$VhQwAhU_ILZ^bXQqIB#F z)bamZOnW(@+fHPAuGg?W3#_XPYIloH@#^D-=lg7<1U8q3n+E1S|F2C|KBqepL}@8z z`cT=<%53yO<jG3`29T4Rg-TUBw6|jV^X+;oT>xZSC;B|ZzaL+A{THPf01FSs(~Sgg zD)eV(<ueZe!Ioi|dX#nYqKIAQR+qHoeOK;gf$Nim*W{46(=IYp%fFk4yb~x|(<;|d zXb+cbijl!<z>;cF#)G-{Ki(=kS~@y9mX`A1=nP^XnvI#AwMiY@$M^1;yStZXzZY|= z#mtQDMX>=R7#I=&w(uned9B-0dv=7ddNib8(6U(*>U-gscmM-IJ~iv}=S#P5>&jS7 zrQG-Apv?R;hc5pU_!~N>=%9G{Bz=en`{+m$m-(NCJv}@uq*_!s+#~BBy?y&O>;>o^ z$P>HIC;_{Gi-2B}Djzf?@KYCB4{c=NgVnsZZv8Yr`FSvRV>+J)DLRz`)k$w&y}B*{ z%9TH35+PmN=A3Q$I;i0Pa9a>P;*!v@`g(eqDaVkP1Z#}<mDX2$B~vHNI`<d!vE%<D zMq~6K6gYqWY4LDGWTd@F{9SZ}?1cNDq@p&NaM{%AY?#N}V_s!{cQ!AB1oAb`5h||z z#(#4`eH|HE{%$0}TlrCnn3R;shrs67L~s*^o!Hy$68j}4uXVm4x`XTq`<lItgXtWB zca5$2FVe~~&YpjCWL5kSRzl13i-AR@L$UtLm$IM&qRGCiq!jo)!b*@_3nZ<NZd@OS z{r#fIA`U{->+Wi)@~3?hwq?!DUu8RcQhok!D$oWfLvOZV3NsTE2k1t2H@&U7Ic!7e z%nRn@<LSCrxCL>=Cz5p(e<EuG-ySF8f;tD&8UGz&)Z~Btbx(Z3#KOYDQdIn$LV7q$ zQevWo`kVK^NP2MZT$|o&ur4O4nF$gvSFJ^cHVQ-XTTgQ5gJZ%FIHHFSy9F;j27cwF z@u1ex!Iq*N3;(uf=2}sMb~@-Mb$$nGh|O1bU$;0c_LhY<*lfHY2MU4`co1pyXFcAm z2otU<fTro`bx+X#O^EJ$3WjlC01&4zb5;o+U=w$jU#I7FU#<5S5-O(YFu8Yc=hca1 z@Uu16<^uWBe-w{4n1gcY6%OW}&;9nTQOBV45(c_13&?zEcMxKHaGr+ZufH<0KLb_? z5d6)xuFL;$Go75M{xn51?LLyz73ue6u&ZQ&7xbeP{XgiasVQg*8{htEf}=>tBBnJ2 zJ<21l@9LtRuG>1u)~|-x@5-^Fel57rT{B$GHa&m2rswiMPgNRwlkpv(LcmbDT6DIw zNIfUG>(HHUua<UAT~rxNFEX>(ZdHvo&db)avs=79?(OpR^Z-v(y|LTqO+ZB@oIbMy z_dMZt_<mC;fquJsHTCmn={FBP`GJCb;*pQA{vBN?IkdF2G{}41`ml*leajr{=BZt2 z(WYCCYbEyQ?J0i{CBX}G8Oie??n>u`=T4@5I6b5hNF<Wo8y;3GgBiJa_iY{hshOD> zF`I65w{_s#x5rPP*7g+kt^a$##LH}Ni{)fSsr~SD7ANb<a6v&22|@ms@~*rRlRkKb zr_B=4#};jV(;y<fEbgA?qe4C4N3h1q`>%k`gn|_+Dxr&~l*a8uh9Rn2g{>Sqvzp?| z0N0Y(noZR;x@lCVK&pt#J|rys&a`~Dfvz4xariyE2OndliwDgG88~{8sl>ETf6V>$ zh>AVQe-pfQe{cd020<_TocN4ub2Xc90<rgRISfBo7Hb}`gW)RV1hN8E&dn3`N~GET zFNHT7%hS?YrneZ#qoYP)af6aPF|`6ssfdN5m%ZYz0wi*DrU1mSXBb5vO{B<xC0@i| zI*C;ObAf~lQ%LP6xeX0><J+%&{esa)vxTL!J|e`|<8`H_{qE_`Sdf=MlLctz&B>~y zq5|pg8UlCiypW(^U&U2H!H=qml`d-g$&<zhwp}SQ?(XmJ41kE{UqYA6uLp=iLDGf| zhji1w%hdlrpN?@dkt#V%pR48k=^wzlLO7(H$;hJa!r!4Jjd1FpBqEAbrkl-*KP;3z z{LjNtrXr8pC4E=Qy*t&`f4I;1jo*w_A(7u?>PJ~jZb53es-V?CTCe?{%&j8>ltsx7 z-YWegdEoUi;XnMqKPb66FAR<@s+Th46ss3?NSD~5tWfWnaT<oI*`2I9>3`<O4&|uH zb<%03({<8^xBjeWor7fZM5I0YNDwD_mvm~_t7hvcW%hJkqlzP|w)HZWFQp9K4{gCL z!rQJ?h-#1w!Rp4z=k}~fhCF264~Q{xq@SPI{U}A~kmf+~G&5WO^!ZA|+CWaGWILB= zs-8mnXkGIA_7~S8QB}3bo4=M^!K}X3vKXt6JA5)bM_v|VCn|7{c;Ie#;tA{qym%4R zMG^v&Fc3^3tFGQ9wsDX2pQ~C>7G|oRNxI~OH_gS1XD>brap5MBXy4leu3zUZIed+x zruQA7JmL-|{AB5XDf*uIN5w;ShflcU!L`(G^9bEKv7eS*$KU}(1(@qan6)^5nj|J1 z)|btO*0*HVC_A`>IQN>|`RDnOk<AX_&o&%lLSR93NOF_z;eX#G>i<Mf*#8l;kSwec z0!CY`C1`gc=m&mz*iIz0Wa2>NKP+HyexP85QR>+QnPzOO)+fPD?>KJ69&n>FsuV9} z=9oC>|NZw7?urhQ2wcg$)W8XrKPM-s7Xgi9YHDifng}O!YT?wNOG{60@iRIG;(jPO zfY1xLv2@NTFbIrs`Z&cc{&FPtA(@|V#JD^@FJ_N6pG~pi)Z2gn@GJA>IHK<A>I&w1 zq-rJkKOdk$L^?I;OzJxV6&<7`o<<hMbm77Utg(Y;b3rw1jUZoAoV&DL!oeLF8tPWs zor8qW`$fzaPu($uQLJ7KId@7*ic{GeK5&F?PF%XcsR}^>-=!38@!6EAqhwLxs!I9A z4VuS~Owd$tg-1mE*<DX?U^_#WsRHN(9*>7cnsh;O+E<kL$)xdG+AZ4W;JOoGXtWDq zXAQjIoM!2n$x<%BM)mvmZ=IaYUxj9agM--`>c}Hxt$9jH`6o#suxS1fh>R>Qfy@Ba z1QWzc0PtgBVQp<Co4YOKWSQlyycOKb2{sGI$)v8_&M)1fKXKv&gas$b{E$HX--=b? zR7=`$5toA7@YB^YXu;BwCaJN3LENYV)F)zL@~X=~45<;fQz>gi`c#>gDtrnSCo%=u zQ$wauta_RBEPelh3Eh*wKWXXg41N1n9cCnzvk9{M)mgvfh@CNrmtYyFvn+<Tfeh~& zL3ie%J0=Y`xyoW1Wi*!FWPj98DJ(kL6|$O&uPFdHiimAepEwa7L<&H$9HcK9Uz2OW z8k6oYyzgl{Xj4Jp!0E~J7g<6O#8^@5C$Ji`nI}`>`R~NJDf1VA3TGk|hSQR(7evld zJWWT*I*wh7<N}5vkcrD#>-9r;n%?bI95DkA4`3yQ(s~>#149;kIYVB`TC0y8>Gg1| zK54<a{FJ7caGQ$03%g(aqvJrB!ulCJXqT^BVDjC|Bm=%v>x?7Xs1~|-)~z(}zXDF1 z75vdN|1#lnxL!euN6WsylUbgDr$9QS3;rQZcEb82-?o^8_hJIohCY(y$pY&Y4^$X* zeUOEh178D=(GW>mk;P%|!03U+X}s6-eAdKd!r_G*c{pMOV3S%EN1hH#N&++ERmlP) z3erg7e&=wEcNd<5o1IAfQL>ct+Mj1XkZx6wg--H+3eHJxKyVgmW{`uuvurX4{o$@O zAqyfN7%NWxQp)`QnpHxv(3B)72#%0F<0p-Dzki}lGO|kf3#6@~&L6;jn$stakj<R& zi8dm6AdxQZ4AZzy>@HjxIQpGL5Xg9_-M_D17YmV6zJ8S|#T~c#`zu0Z*(W)4v6I_u zWE<et;5S~V$9wX6{8#_bQ%tDzQ+9foPk{O=jgR@Pyae*ZxvG3Y_-a0X8v#5OLlO9r zQzP#C?*X<`p9umWM{0c$bL{9b6OXyqP|Ah22nX?DIpp=i*}A^-Elsp3h$tiU3iIwh zVwjDH<2xE<4gjU82eyw&++>H~MC6{lLQ{2}4cte`QmAf^dK)_1Z4NJ&E29TS*hrn_ zV?YfI`JGOV_H@j^Sk2x~4|Bq+&S~%M{a}XTYd)I~80htjT@x4mfyv||%QQk0CrxYR z{2~Z@6*Im>vpMmVYN+PR7UaKM7=*iA2j4Q^GOFBSWG?-LzGA6B{Rp4otpN&y1nAJ@ z^QQ?DkvIN%ZO~VevmiKFg<POJ(71STB=eyx`Jp2#nYLqVR960Po8Al_tvVo@@fP)= z%5(zEfaj6VN=HQ=4V-{$fHU*L*XL|)JmTVp^~z%&d+j!wwfnY9*@7a&O=2?tzIkn4 z0RhLYgcb8TYv-u8an+=3^_z*}Zj~uM;^eckC6PxD0wAPgQ@PfgB6IBNKz{l&)^4j7 z*jwvXem>Lib09z=VsP+}+00B#f1cpQQ&p}@CS}V4<YXBSk%=)oYm|P)V6j-Y@|QT5 z^zB*~k;@EHgd!-z*o4#}aZZW=se0p>v+b}eeSdau3UHAm6%e%0(3mb8v@5pqbsPN5 z6DQ(mjFE|ydFhWLv}8c7{JfpS){f>^0nF18h2-YdgZ;gg%Dr0fsfDTx(=j&BsX|b& zp=+Dd3Ti8UH^xK#@>tO(f?FQy`@9bj0?sSl3L?1<`||W|D;%axshao-#E-~LqvzPS z5tY0Z`2JywH`|Dd8T%E`LmRJYA}1cDDB(OCz?nSf{qXi6?hK1S<>r<+^dtJ91g*38 zyPGEWPM6;kG5PY@Fm#OQ^Bdq8GuGl3i;S%gFpIsm7WW4*!~)$FRNvE|Nuq01<XRUw z20U5@5C>SpG(DW*K4=ThFrh3aKk;F$2tyCeOg!<v3Zd5$`;^YG+#wKrO{3Tdo~Iex zl=7LJCKnv5wKx2C`v%6s#x?Nie3ca7nZDn105n{vBFGI)P-B!PNSj2@(^!RrPw|d> z)59&xLe)6TfO-AxaZiQ4bwG4_b+Fx$HGNO_m-i~J%7;WJ>ZDqZ_S*}T|H-zs{PA8} zPj7ZDU>UK8<1>Gmo)%VcZFm)%R$yGr2k?sa)qMNz>^z!!92$|h8p|DD%;&nkzFxY7 zH=8T^@gw*4n782@3l!Jfh}F7Wp}0?@3SQIIrdzD-;bJT10++1C*QW#a*NJ21z9^Bk zb<j<O4qkWN*Mu;GOnc^6;p9-j&heSzAK3kEM07`j@E$hiz*Zzar~|Rml-y{9fC}ho zKEo+cG(`gUke?PBR(xmalwR@EzNa2OdrMP;#T_3pXiR?rfG@_A*WN+JanQYo$d9@2 zVv6NG+TY)F`rWxMdFO!}-xZHoBfK?iq*slEpjzLhIsNNMXDEkP#J~)=7ysTHgw#!j z$)lcp$s)PAjzCT&Zz9xS_H}AOS%UM}_PASt$hE6iW#bV70-Zh`uu5$#-M$Wc@}5(2 zB=27R-b&_q0AUQvZPqOZ-h(f6i*kMt-F{JuYT{7-8uCG=PbRZ^MM-35PveX}?@5$e zK9pIAy*a9y^NNj#<kiS6z{C<@WHhgQunoSat3judB<M1|k<}i5o2N}_s?29KtPP0K z5(GAeINOa!9Nh9HWF-4+V!!1Z3n1IMFoi`tf6E4{*_E0OJxrCa42}BnrzIoVW%T`E zscl*H^!>BKZet3QW1#^7&-40A-!6D~l#L)G_p({NXV2kl^AQ}e5izgS*HODRe_pLY zxvg-^X0gEc*3H`^yc}^NCU<2$;72Wwx{Y}*6A#wM_Sh=v!dG|-VD4cEJyi259bbN- z<F$vjf@{{^crDkM-o0+`?Vy@(%gRPu1=WnLgcB!{O7Fp_P^uQTUQN&E`_a*%s;U~j zFJ{%TU2LfeVkPHx#li*KUET^(0+y+S9M@32gDojg*mh|3;5q1SN(w8k0rJp&uuECe zGP?MO_)4V$1^b-}%WrRA9NU{Q<Q1GLnG<R<@{MeN%2$c1sO##<fnB2<7uW_VW~p1J ziT*vQ?f&6qEh`(4DT7cV+pw(TZB79rh=`Sx<^fTE5HDh!ub5wT&}d4(&upBA>9lBz zR*Tzk$xvmXJk8wo#hz(^{LKiO<$3#cF~vN?j1Z#=pctFX1zWHq!Lh|yops>>lkK*@ z*-B>^T29q?ZiTIDiTUz*UgNup1xeg|(Ho5~Myh{f`^y%8b#--x4;V>4oA3jHJO8dr zp%K5-#;RF<v8x->$f0KGjA8-UIbc0gAgjLo;R9xWYmeSkRY~d2wT!lP-(8t&9=sge z>1x-KnY|`A0rd+B3bIwl)c!rLtfr>>m);JiDRC#pEc}UL%Cqr(YHq8`$gl4~Q8#aW zi?Kg{f1z){1OcL{un%9|9LAZ?K6?|?3cG8_MKOSt@U=K0DeJB}klvD0p`yd&bOaZ1 zIdp^#*+R@$i_7FqS3@N)AJa8<`Tmt!vMcSm8GaeJet_dC=rCPfw=KZnTiXO$e*#q% zySH4kH}G8<c#_lj_4oozaA;8Ei%$>Y%XK#YC>L1I^{qaRIeoDUW7c#AdN1mg&+4`q zO=$~V{_tyJu77x?Xef83dVgBZ_zsvytdDL>?Enb@;*-jhl-1!D%{L}@riN?_z;GjA zN(y8p{T+!_Y3j>>_%ol=rodiz9rxa2QCyhfG4g&s(D;t=^_$mECpS$OlSH3CnJOJ< zp%bzD_3VdB>h8v}i=dc(&({;lJ_k)b7SvK3vp^v6RMLFU4~6^yz|#r0hzMrku?y$V z(@a!9FLN5mkNCtYhRnA48rNrwr7%-07PcKK0Wdh*=$bfFu#pRv)_}7Xd5zmD6X~zh z=w<Y^dG28L=IvI=)#m26D>FZklDU;?$zw>AM@v{6?~t)V)oAJ4gzIfKA5R%|)sCvI z*AB#8Ds>##YUL5YtTs@w`u?%87pWS)l5SY~)CFpb)_oHeJbM7oIQHfgtMjIC>K4q; zaUW{bejhz+P_sz04c5@HmY=gVKOBM?)l@8Nt@ro}CY~!JduYc2m=^q}!kXQU81a1F zcjd0wKm>q0QR|jf35sn2jv!-JuuPMDVWJu`I$-+KEEK`PxgmIC`Q>(g`IB-Hjr=AD z%zi)g?LB}Jw2ynxyxr&FvRB(vPM@2FPzpNK44mUFyI)<PNQ>P2MJ+{mI}*0Na|uts z`E}cAYOC~UwSPn#efHM&cn0i@dTH#*-gjh<zTMqiXpg(8ozu!b^u^}tk$VMW`x+V= z?0>=yY0`7+*Ipu!KYxE!fMHo1FWIYYru-*hZ>4JckAoyf5X1Hw=Y>Y!+*WMW%1o<Q z)famsM01h7ffQ~_d_|GrayQ!hwI#HausT+$x$ZoB>3i&+C+vQkpgwA=OLQCAnkSm{ z%YSj`dkO=B>8Il=oTjw;v)aRnt6M_k65bpqfFNFf5poJV51`}lfu!dq`u_c%oScGM z95lJ_Ia8=IKbB`0lRr9Do@-E38GJ-XEYP@x8R1SGU1$n+Wy>Ny(S3tu%VWNTuU((+ z$bvp6BB)i4m%t8sb=FWfA1sb!O}5_--Q2kpXiPGzu3OQ|G&pq|)bW+uwUnKr@e1_k zS)~XQ#$xr_R*t>X?FOkYZ4(|q>ulbE8^~lM{!>rP%#0_YwCghGH^Ei#&Z)|mzl9vz z>Iou-N6Lk6w&hvkDcR1S7qsb_VP%;eK@I1@LfEA$+u2Hgfzw@rvkGV^WbYnm`_cB% zIoCoB*O2(BaFY`iXlUQ1+T`Z3VlQ~&9iSlp)CdgNlzam7`$@!sx@tSNtu<>sU6z8U zseSb^ZV58}X#u^7+5+>HrLh_X3i;4l9rSz#o@#2p-_XG|QQr3Bm8+P8`E^#wJ<l{< z4-XGyn=S?DuFjG4MGLV+!nhOitMBgyxuPhqpz~{>tbNXSx*&B5_nMoqzr7jiD@MoF zG7JN};Nv96jfiC1fb?j7LlaLw4k2rqMsY{X@9r`6yMbV{@IbIAEmaXDB^m}<#bu;| zZO5OP;Z5%N&FC2J;u9BGFnDC>%y;^O$(wWIL{CqAAT5t>{_bLNfZVa%y%mgta>1?N zzaK})J@qk`#I3$0ISi}}*BVy=^qae9Tf<!pK@sJt*_m*YET<2@(4$qB?FI}$ayOC8 z14d^bVt9>id2PAje1a(SXsS@JNxG;^LCsGpnjyW*Z;)y|!pPML8Du=HYLBLK2bYB$ zho)u&6#qmh^E2o<YsZ{sF>p@%avwTz%tOzKkt;FT#WZYezTI-hfguAOZV-=Qvs=s} zMxOC|MsCHJ(s9qYg%=Y(+Aj2Fl(|ywSk2hAdPv^z{$(-m+(u5nq*f6Up{|Gq3#!X` zJpRi5MjFo9ybI4?a7ObRM<*`LuBKacvdYoVjig5+w|*Bnj-x^qL2#x)rk>r3Uqj@` zwO$r!3@@_jJEpw0-ifhhN=4JK$_JOv$B-iqZn&x9dw8(9j_C=b$`5wu<3Aeb#XN+P zOdZZlQ!s5{;j>|X)uWX2E?$J*=dHgNrAeIUCbI=}{m|hv^i+A;lU8_Vv`Sq+D)raW zXwLg5_le(^3FL^jszpLjr89r!{g{rB@P*rU*Y5;887NSP9Cu;>G!+QoEIA%XBBz=Y z-NbgU??ZFT?3j`JB%X7AvBhHxA_I#@-}-bbLSrI#KE#}f1)X16WX2dwf&4Y5#M`k< zB6jMF`l!2i-@Si-QCBx>IjF>;ujtolYwd;|ji{1r(2ABz6+T$#C;s{z_x+aV9p~B4 z-1w^d6C72}FcpXF7%K+FE#^yJ2ccXXJ5u^CF-dwxaihPmMNRV1`!?`#_Cjvp!sawF zA!9!gByi2cNG0&^-o&|lQ%wCNZ?$E(iQSP|P3`>y-JwvTKA*;&`1UV8dm+NM10dx> z7&jyZ2X6#BZ&MmYHEg~VcoBkoQMFv|vpnL#shZ$EQSVP&AEzgUD0#!*L8hGzvX4Ps z<kZI3v#?t{Ts*~Rn8fG{AWZ03YYws4Gb(7%ZY%R6PjI?O6;C$vcZc>@PDQ`^JRir9 zZMXEQ`8?-zLr1O89RU|Kw}O>V5VDgH?Ye@#%!f5m%gQC5xR07MA)5<*nkJhI1<F&e z3@K`NcbS$N3xc*DC1MT?Frewq8$N@zKL00^P%v7}r_=lC0q{IaQkJlgTzSVtkGap4 z!>t&3?Li=mK-p8M@Cy@C&U@X!0K$`)+9M$7j}U_W?m;K(Xz4UJ6&-=sZ4td5t^rAB zbno&xUDGV3bbGXscwer*H~n3?vMH#}9je=45Ylc59NVRUvdf+}!Htw`6p`n56NWh` z>QC>9WW^g(2Q+uxtQO_gf)P8k?BerHY_(o%>AEO_O|5tT6qhEJW75lc#dX-e%;-6O zwJ4Wej6;seT0<ki!_ymy49vcFpt+bNVY~?1%h%kogyF5@pC_*V7qc>stHypj-Fzam zTIs#j{CelUGE=^0RkYdkRSuDUQO~K&ruR_t)-sAqRS2a+5MzgYXo~Nj$cZrgWbj9G zxFXz&=G?i<D~bxrBV>8+<kOr)-gR47yG{u^*6inx9Q1XhmG0FYqueOaX&7~+x6gqh z6R6dJWi+Tm-I?g`pIkXi_&#$X(5EiC-7@mQQ-g+d@`;cc>IP#D0x1%VkXV}%nnWaC zI+l3HWpOZ70VI|mQ&UOZU`#i=9_v~^$WzWRrr^BBsv&jNV<g7ZJ320Ix0gW9qeG}L zZ&?ZOKf`DI#mJ;nY8^Dr4S}M@BllbM7JJVcb6DO3g7h9rZ;@^(+9Z+R$s$F&?N3f~ z{Q{t0hai{mQ}y19+G?tEsNI%sj1BD0%NXL=M<I#bUl+hZ4+e+tvT3I^r#(O!+8s*T zIyt4*1sa0I2?bukAQaF0OAjP&0<KJ7>;|(tG!%mtVNY4`NHZS(l6~r_C=UU`!<=6$ zSH6gO5jJ;-{oSegXrVcni|wtg(ea%bOoB#UjiFBV=-e;XRvfKsHDBC=6-D>Ek;Yn{ zYHZRRDN}bGwK@*>6yWIlzjb2NJCesk8*5f>mV(&@eobIVspI9=#t-8vS0L`71k&Z& z(8+b=dZ4P3B3Is)riuzg4GJyF4Iz-C>@-jbI1W~BrpFLD%{t!W%2Df}LesG`H8wzO zbcpek?uJ5I)WH|kmB!5C+~7;H#lu`!O!MOwhHYZV?E5v}y-gAXwi|Z`YlxkFh0W~$ zcrQ_kC5p=LG?NIO@$Xi2PGHE-{Kp~=LkaQmhK+JcZW9yrPZp>k@{nhO(vElBFOx>A zcxGPql_b59TpX!T&R;W60m1z8B!}|+&nzIW$NnZqD06cbx5O@ktTbV567<yB`@Z|F zf8u_SaCxzsrsg1l<?PeBhHiA7fk(YxMJ!Z5R74zDVeS`#8eU6TaT&A18VaCyQ;ANr zmEGN6Rc>n7MSRXL(?|L(lx(Pt1Io#%GGiu9Ol@lY^zW+*fgMI0WtDp838!n#yM`ST zJ>5Slh(CD8L1a_g%7w~?{!IJLxj#R?&I^aEoqr{4-6gGkg~;Vv<+k$aK@V%n37R0l zCH=TlHoLl){^WCv(k**Fi~-*-@#zE!Z6+8*3te664CQ-cdy)qB>|hnX(VQx$+w&cN zovp%RfAwmpmx{sKj8cWAy46awq2spuk6th<I#?kxQ?oesR+f%@;A>OjuP|3nE2fq2 zhQ1lCZX+IL$So`H!LG}!R3WUw8e)bwt+!tkRL%z$jdaFTwX-CKW_fOw-ZpaU7Dmpw z>?OL{4n!_bm2W&tQo*eLY0Ez|YCP3Y1ZWp-sLCz*Xa{?{;t}-bq-ls;ptcgz$55$* zd+7GMBl~7%ES)^n+hddxo*^-81SZEytSRvTlZ=X63A8TVu<uN&k@Ir%oEhDgXk-RY z3;-~uKzGg@s#u@l#RJV=!)?=YuHuDr20VWtaB=5wwG%U@wTTkn%_5>iBP3e=*n_Qd zpHI7>G!{(+t;4pAmupt)KWdKqtQ_NV@;61H`<`lN()_oS+3jsD^*Z>qWj#||Z|1`F z>5AP^Ox*h_k+)zZw$yL%tXXXT-R;rJsj_kGvL`e<Pp}VVbuu$B@Prb<CMD0USya<f zSxa41R&Nq&+7wW^hzm`i=WR^}#MO4mz+ztXt8t!zFb6(DvlGj65EThtg1La?idz2o zRkGn+p|?Amy4_aJDyF#qw1*ygE+G}%7Te1Jl8R5rYaIxGG#hQWqKA2$I)WhnSwAr( z94bD@iJT{ZQ(I*1uE9aI!_QbIL2GbaO9Z?Uh#f%E6!T@xUxMId#0Il+;E%#vJcNq% zAbwm+)8x~B6;Tvnr|)V$x*#jDQH=4Y3gJ&9ljB&mg|d;6T{q>6V{+-@Tst*Ndusws zSH6RDTjbVG#U7it`yh_iAoxoCzHF$(wK8eQp<0X12grT$-a}7mQeL>wWQ5Q`Avtb_ zFX>m>;S=3PbJCfX^SH#F%cYsh*Ymwxlkzj1c2>hl<r&t-*^I*3ZLE=h&n4}#?%-!$ z=TFGu3Mko-&OUu9GB2t4tykhOzvp%}Cpo>hAWzldwEuCH8u1I`i)gA#Rr9{{TWINn zJFL?_Yv0)ew9LyvfSrt*KcQVg$oPf!Udj<bP#-@Y=4)f-Sg9Qn;(9AGB1G{Up5SW| z_M{AgzE8#8!B<-!0A385$5nB~4hK<E!mBR#NhOhg1sUxM5`WNpe|wuV6`h(uXa|94 zcZX(Un?JP|p-{oZcQQ{d2!)O@`q+$p<@#oOQ`fY5eR&VmjM~I%F#=ARIQqptKH)fO zW6j;8f;EwUyAKmDS!{b-p)I6nTrJwLy!&A{bcfDQ2yU&gR8`Gi__A6~J!Wh8<;|(M zu^La*#_v6lG7mJo4w~Z76)j@GS(IiuVm2E&UQ*B*E7?}Bj&3`F@dE^r&jGQ~9=zXk zYpr;Vgn23##5>j)cldq%+E7Aw!EM|{LH*e(K=m_)Y=CqNo9)t66k@!#lNKDy6+bmL zHQ9Ea(F{>|Sr$dhodWb+A{+NQDT<)sh~Fg;_xB2$k_|xa)z!uiYSB~2PyJ-o+U^*3 zL~RYuKyO5T<r6*W$}?(BzRr`s4B20Y>%J{^h|00AO1ld(w5d3KR6cRLRT)Zga?`kt zD_sYkzOX4fYpI%yge+b{=OB3|naSzv17z{zsmuj-P21rw?Q3RD9K4zx?h7xs$Db!x z$OTQ)8~>Dh@(l_LsF{|JpTxWu{T;~j#@2MJtrJmYw?hkPiaQ}7!=ef}G$S>vEttvO z9#`Quvz(DbX)l5vd#1*1ymDrY654L+n-rDF{{6C7)$01y0){mxPUG6S4SLR1aXL<N zt#ehQQWmYZ7-q){9J>t@m#4mIc?DPITn=!bp5r90R9JB+KJ1v;Yer0q<Qe+jwz5=A zOloDoL#u`{=iYY)iN1U_l^_ZE9z+L#fOKNb%)vfMog3-Z02}|1O-Vvp2+=&j^~f`w zsVhru0#dyWU)s>Yu8e%+2Ki2nW9~kR$`zHNH4YosT|@vM;HmnUzJ5d=$!4gU3N8R! zTYxHU{P9&bNOQ^2Ilm6uR)Trki{74``M#Vs+ED>2RFo|fxz<Ug{B>y#K*SuwKRVQ4 zryVh|klPF48KROOoN#9RSs?JC^MRS8x|P{43t0<E7IBlz1jNqWqfs4+;tvkCayXOS ze_U-YU+5S%_5O`GFOt=}^gb;mRuFHCXAr2g)Ne^{^Nn$qt<CaQI5Ev$ldUf#6Zk$I zBhb(IvZ|TV=U_17Je7PU>$Taf7RV)qK0Syo$St+#g|;cGToIP-@9CDj6P{q#bDro* zJpl-IY3R=1Vq$U7D#oIaxil29EA5qggb@2XxMulCoyW9av8p>nZ?w;@!QMFc6Z?eW z*Jsp&eb3D(hh{FjCr|#gM7AL53>z=XscLVpARe`JIu4~3=3EFXT0lJeJn{mApDsks zXT1{xUtRE!wH5oB`zZMmGCa(E_OsDgUzVm}RX>ia;+^+w*N0beC&2HJe=^$2(z4}D zjL)wW86PB1LSA6ZDRv!DOi)rP-jmT12i8q38AHVG94?R%$@M@j_2=qqqSm#^)6&wI zRU~N6Gmn(mL<&uxi!fartjU0iGia`}h=F}UNpaxw<CjI=nk_GL`x~AZYEIA0C?9bn z?3_4Z1oKlHPg@W*PG5z*PR(-1?&J;Or@uZkbMj@1`b3M9Nb8h)g@refR(cd4vuA5< z6~Wex@ebPOT!nw`DETIx*o*uLi(uu1$ZTGum5GT}>HT~6+=17pCBuB^948_VgJU4L zQ*?Z@|9+C#D(d8{^;6%+x>}RL>Y(bp&N<8CK_2n;Dnj-z$sE}eL>W+<fU1PO3g=}< z{w%56@MuWU#5S8B0&IzZEdyp0_+Khva9KVHTq%jzRt1G9jbAYh0|NsS6BDT)q1GKG z{<?AX#iMfnki&42^E*b8#K(_1Jw2U^@jS5FEU+5~lT<5yooV7+K+2zke*lKLZiStL zqs8lP!=Pk4J(*uNNfkf_`5DHnP!C?n%o!lK$qtPNF1{md0AZ|69>jV`diOr=memJC zg7+%dJZ2bjp=ERm?QMf@O$U+^LTM5WnB3uc&5qB()t|u6bc%wU%uhg39||^wwQv5< zrvRhhVW~J3#%2-UE+2FX_;iwnjaHVVPf(#1sPL|r);1?Qcmou4UB&AN_~YbVGL*k9 zaOCOXi;CJ8Ip{%S;hsK)BX2z<n02ALAe2QM8vcqZ%zimUTr)snn5UC-l&sE*mo5mo z^(LqK1zc&uc8ss08X`eIL@P9*_1<{~!UocZ1cijqF_jJJBr5!<K(vtw^tr2Ji*2{` z=xlB6u|MVrx6<*n)Y8hTO&a_;vTu--4+S1&Q#g{!=b+hKP)<RiO&au|a5c)3EAszu zIRPn?(F_j{$5rvVU4)whZN?ZK95giS-{0ZRlP;&`Q8R`6msO8}8^T_b)zXS`RbwI} zOFWb?@WEH1_z(!w1?|6pgFgz-p$-8ThPP~zDv9B`a^)_GgcsFS$|w6rB?YyVNH|~p z93WU+TwD|{FM~37KypHZBn8M9RL(z1()L=E4TrKya>tOm_<<4^`2FG-xYf8g?qeV~ zsja0T(fV3O7STquO(0;AZaGaUwimNqQc?mc7mzP>^nj8U%P1WfQC==u5s>E{43An} z25p4YW`<y(jthh>`A2efa=NWm?%)4MDiPIQXx0Rk4%r}=h_&A(=@zg$92~lGpj)UL zhX;gJx`aSrV4m+5$WoDI8bmNxoTRFX{7W$#NfK$as#0_4R=|(<<E;3=LPL{wn*L5p zGRTOIfW~SPYG0I<m3!6f?!lnC{`!n^J~HKk8`<hqCB@mfxjB$_3!a68mX;&ZI^_r% zO1a|z3i3Hvdk&&r-Ov9Ex?E~$lO8#Zav6uN7)kGE1HKT1+P0Ni!fcxwCOAvK_*1{q z2#8m%U90T0yTu3qTh3`Abe4(*w`ON(7A{@@Ll}An6M=>jxy8oJ%$xpFDp^#u<vZw} z&{<JNGdeBW@-V;E<BoGyjXFG!FeN&rhsuKJru_D|$n}G|I_bo+4$?g!+&a)k7Wdfx zaTg%#Ds}o)<^iu9j{hu1Kw|cPwl?hl-reEa4zKM0Q}uViMn*;?TCU+F;S&_3E%6u` Ya=+%P*uA?xr1z{Sr+P2{?xVl|9|=c9`v3p{ literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers-models-page.zh.png b/docs/user/guide/providers-models-page.zh.png new file mode 100644 index 0000000000000000000000000000000000000000..b5d1f4597ca57fe96fdd7305f2b52d972c8d2efb GIT binary patch literal 70021 zcmdSB<y(|n8#jyv8`!|!h$2!VC9NQ!q=*O%(lO+KDBazmh-^BCF6kZ=h8P%9r8|Zi zy1P4`1$aNl@qBpyfcN^u0avU#&vpJ{;jbVkNks6F00#$$NLuQR5)KaTD;%6#`hVR7 zzbWI6FvY?72S@sin2K}aD)x>Z8D`}7mVXtg_)W3T%oT1nnAbf@c`CV8<0xgTZ&3v6 zWKK?>zoz0xe5t4!=NGiEGUua%kZ)gjxE%<(&9|?vu9~kW`Pp^GS`DLQb8jCV9ia@u zvm1Z^9vL4W@9gZXudnYM9Bk<AeI1*irJ=zB+5oOYgn^;q{Os&Uvy9BlOk*iJdiq8c zKmV^v{{{?)R4RS5w_~HzP%ZjeSy>sGo*o!jlAmveG#zMfZzsEc!2>q7;D)`3ou4KQ zOiWCSjMB}F-$UOBlNz$j(SKJ`q)_v{qwcCx#DIz*dBn)Yg%A*N7|T;tzJA!+FS4Lj zN8E#glG1H&d7!yj3cr}4Md{=ti$UhRjBmNZIF1aW4PG5ZC6~sgrA4Vbfr~_=4KVkv zA0G?OGx=y_BolP+{{86aXv6pK>FA)peycMXB;RV&vuCVYSzMePO%)4%$or?YDvTI} zZms>25(EM{`oJ#1+{h|(kkink3WLGa)YN#PQ1FQn&X3pc&r!-V`}y;ycD3^}I=cGa z-XB6f1W29l5OTdG321;}{5}|SHkmd#YQc)i%0B~|GcxvE|HB9QWT7}XJhOAVC`z?{ zcAb&-wXt=3YK|@fmj0HAZ8^mM`U@3;QttLs4ZdMv|L``l+PSNw(Wpx}RR2JJH^CGY zqS5Hfr2?+8ccl4Wi6{pPG<Fty(kUn??%%(km6Ziq!s;iv6QNtj#>NT?*f%thuKTNE zu6M4U@zx(29(Gt6EPNaMko1XkK}HnJM!1+j61rS0=3+UTg^Y=b`QPgU{QaeO<~y)h ztbCFHV~diB-`$7Am-m_)$jOEHR=UJ+CRv!9{~5*9pF+aI)KpZ&)B;gSNfz(lD{lYY zR`*XsT5mpR1)kdpjW8+_cH^oQsehYWSX9|BJ)VlVEsM+Z#Ao*wyN}uY+#FbUsH!Tb zrCyEcXV(qJd2$y&M!;!(b$*`7Mj4-{zzf{EgJiqdMdvA^T~8b>+T2UTuilFD%D~ig z1m$4XQVba)S$XTNs-nz`wzaXb9LUT3(<zmIT8a}B6MvMHSjD}+cU%2e2K432m&C;T z)PnY@8-JD%YuHa&i_tRVj~@{@d56(izustrr9MADpCsV;oQLPnI`t-C{HEL#Le1#S zD$>Z!VJeo~+_5&6OJ7oU>$;N>>YAEmW*AOrdJf;$TkQA<Zf@>)UMoW*qwMSU;=teD z%wdv^|7d6kVK6BE^a)2M={V2T0U>}iWBb!3#-08B{oUOtsn$tz=<$u{=d8`B?Hw)T zY?;sX9Tl>tr>FgTdDdSYl)d4Ia5#J*L;8cAo!y`DtKeW~XMf0N{-i!CUSWyzn7>Ym ze0F|bKtN!yPz!oJA2^ouw6wJNS^dLf4m<^vH=;EeyL!`9+wNbcDR5bjg3K{DJDXEd zX~x5@j3tkNxwyJYb!T4x8K*qS$H&Ld&u;`@=7_nBdmsy{)ZE!=*`E{sXV_0A-90>z zNyTKchI<=S>JjFZm8y33)(Y3pdtdwqo-ZjW;j2@!U#MeU^c*Pu_P-~yA3iq@i;T4E zqf&p(%>l;FbRh3fSk-y8g2ma{DL0A_W}7iC+Nn_UPmA?#SEJF4#Y$}#W4=#Cbxr@w zY~5e|1sXi^;)=HlJ}-@8vF#ll|MxHI>gp9J)c9Y;+dDgUwzj4NA5R}$w=YPlP!rjo zJ4SeuT@j7?Uwk|u;^N}st>o@#YXi4)|KD|ilTP;uo}Qlm1)2p`&49Mf|Gv;%QBfh~ ztormzHx?`VXW)Z^5)!mvu;j|y>PlcQxVq?-3pIIN_?+zR^=`6X|LOsqf8pxdTF2Vz z>UFqnrnL4pcXWVnE2pRu1o<q9GRP~YaPX3;h~OjIEeb5IZ?hhi7J<jb#f68{b$wKQ z+o_lQL^>LYM8e^4p@i7jCplEM&K=ZL)PL@#lqQP+(Gee?SNM#Fhphs_&yR833^(Hb zcP2a~1+hlO+y$Z~LI^@9EB<iEthqcZHEnogBwj04>&aC^C(FX97#JBP(%6uKp$uw# ze0-!j8tT?Ami-Lxp7(VRzIgf408C+awoC-0OSF4o#K+uc(Vqznz-4LD!gc%dCElZA zH$!G;XUE30S+q1Y1zx;phF5+TjQ0JSp8mnmFw4C}LqkJaS~@}w5<DA0!1~{*dOJs~ z)!$doLZVYrXvxW$pZTRp3o}eULZK4WO+J47C=~=xN}{HvO-W8(pPp`b)J;GqN^$pP z>fqqu^@_=%ih%w4`BSQh&H#_-juYY|kwl+W^S}UH<9n-ogji%^A{F*V;#DV8Z_drl zNxL#aCrCenDed0MV{LA44+;&9L5z=$Rcj(mZ*$EtNcO(b*Sn5Em7A5DAt5219UaKh z(x}~lN9oI9TsqtZATJ*Ef)%{vrz&?_{p#H+1Z&M=T3T90Mn-p3Lj@?Jf5r^tseTYC z*nV_zl~z)fq@3xN;9tlypsLY<fq{uQU^HyGpFF|)vsPGPwzjr!-+mRi6Y-h#UD7QX zwAi=LKOWaz#Z&6%Tod2iT$Wo+kC?p^ac#MC;<7$-)BXA5W6&=~28Ji`Qy<z#h~HaU zax(jn&HmKm-2U+l8l>*-ev09#^;r6~0G1&E7aVbFucx=Am7`o0lAbQED8mJ-j!F(M ziV6)z%KG5$T`&9RO3KQNjS*}bY;0_Iy2B#t2M75$I5-#?f7REEiLp>@==_zQmi9IX zb<iG{I^PlBk#ZqvVP%EJ1>K^7Ap<6wKOcD!Q+3zoWMbXM?6&(>l$l;x|SruS4in z-x(`6w^9*OSXg+;^NtP(Ia|xv`1sb!`nuj;?s2*=exX!o=dB1bIXv8bRQ5zTz2tSv zg0vuzfDEXkqvM=BV%%lpOxLhOP2pw&pGDmHp|Z5JK_cVXn$x(T=jofNV>T$FHH})( ziRGS{9RAB^4AW7OZ!TN^PeZ*AZjlCBe|k2zr>E!p_wV$Q6IJFdY5QxV6y`$uKc8Y_ zP7Y}Q3kp(#tZH|gkjGFSJl`|x(HyiGtLQ1B)<P1Y4pu$(S6|A@hblAQ?2kV@{PI>d z{yP0HO3BDX8o9Z-WscRH?Ynt-)k@NMO0h)F&PH{2D`f8GVRF`2XEUN}54Ts$Fh%$J z4l5T7-A~p?aLFAu#vK;J>QTHukd1z!PcnOobVe6a5^UNGn?h)Hi><@+^V{$?YO|wy zYtHZtJ$JB`b_?X3hcYk-!4#<cOiWnjii@o^7$yw6Xt3~rM+uODilp?vVmNiiixtJa zYI}Vj;E~Xv969XUV-*8zo-V&EW+)n|ap*(O{KmVJ1g9O(dG-OysXJ0;L5Vg5m5JTL zSd?@q+UoNc1jJuMQ!^(!E3Hz{TPPtu-qfr=X8Ey2W<lC@e9`mp@NjW;jf|kUaY@$4 zOAdCrYT0XowYh7Lew&%PVAipBnX!nd@$w0GVVB<9$kT%fvyhY*8t-OUBAGkl&+_Xf zY^Msy^{9m(7Mtuu<7Eq{)(E*QM`bYFWezGwB2}LcPgEZf&eE5f4l7waJvb2UU)j=% z2s2w7aS%NdlG)EzO#cco$-$1%c%44mGr0I9&hWXyno}Y7wyl2iQaHFk8AmCoLi5ss zCh7c(5Be<4cSpe9ShoQ=>qfzAn7Epq9b;5pq(erp*MB2AM>T(b8Qz9VOyjsU%_uM5 zrHMRG+GS;B)#T_eP!wlLAx=WY#g9Mm$aBO-%pz@+MJ;?57aur;mvM82R^eU^isfVS z(`|NqUnTtM>FJvx)P4C;IUryC96$eE*wwFv&2^`I?mTs5gyb^v+yUEhV=3M)ePLhr zEq?Q{=c!(`^BoDfyq*F&4ty5c%;AY-<E|&@Ux6`+L;`zrP#)2<)xiHY^G$C_zb}H- zWR!Q9eZ!#a*K!}|zkk0qoK2fMXH|0s`y9a!^Fq2haJjggxqi8=ehY6G<5;O#VH~c+ z4XPv2(@l#mn1`<G(SRyj3=1QZ)iIcu1b(wYv=&sNZ;tiFi;tVBpCFlvx7cmmZnDb| z61p^f`S}JvBbrM`CE{-xSp-NHAh&#U`1Rs}n>XVa77Hy@-~SmYf3bO14tB{ASBr9S z+7toE(Hn`SP=2s`2mkk9e|~#c7ne5v<uG~Yo!%JCl&NRQ%{A5FeBIaAC!Z+9Xh3MU zGu8ODRUF~57w@o)$uBoL*l$QM9qNYupj&H8@rs*UU(eD?k7$>=S@2C9Pd@Q!&F0%u zy1=-&Tz&n=v>mS_jTb%NN=lyfSpN!2ZEtBgT3XOBJ*xZUvwgatyF*1yof#Gt#n6RE zLfy;P+}b**<$+ljrsTI|HJt%KOVDX88Kjh=B2HY&-Hi!(qP$Yigp(=;oW5+)w3;!r zQK(Q%b+!^kj-Q;|y**_p-w}I+R_-SiX>NAxZr+zqm^B-`+v;hC9S_-{>;r`@ot@{4 zk@5rEb)RkpXg(9>&|w)#)HTX9A{(AOn7D{PTcKhJLOZo9%L~I)cqq*%uXZ7Yxfb8N zH*^sjQP%wlV)CLptK%LgNUpAAVNAM_6$=0>IznD=E7ur|Dy_HD$n^E~ceZWilSB#~ zSHnIF2@Z{(93Dowo9(S&b`B1-N}c`7vQ+b<SNP5UCJ<n&k519GbD^N7PPgbTHMy%H z;Y4`jMpMnjRQB|QL4CvH6N}2Ksuf&y7M9%Fjx%xArl6W<eSJkbcWw-Vd_3X4Le28U z*RO3Sx^T4LT6P8PG{L!JaeHnzEb>j~{iEjj`EvSb7MHIErAu(z?Az+D44;zSFG_b7 zxTJ=Mhh2K#oQ0_qmuenv=f`c~Z<(To)7=ihHk`LdI?Tj8F+F}8)z`0s)*Op+Sm;b~ zvbOFfw7IIkR0H0SLMod`M4s-Q*La-d%sEZC)q?fyK<cD5dXcsBGjnp%61&k;(;q$X zI&>Wi;e~B@ZJ<9C*O=_w2X*;qxg~1uK9hWWce#}n@^pAXw@ACvb!qy}fSC&a(2GZt zhsyF@54<KSm{Ssq2Ay48m-(EJ;Fz@ZbXr&Icu<6;NmKG!aK<XQ?M;~3CrB62lVIbW zCS3B{Z$!V+{sk(NYc3$HJhFt5jcsNBwmPJRoBe|vf^6pRV$Z{^g$5@C)ieE}N3!y4 z{)Q9IJ8LpAp9hW+wWr#YP?Mt*Ufu8d@XMV~<f5U?bPjB#rnd^9FpsnLxJQ{E?{jIe zhE9)4u~u7k$z3lJ)w%h3*7%pnQMTRarO}J=i|)QYyWc$WEEh=WyG>P<l`R9u*|c62 zt{o#4LqoX-@<*p_2uO{u#|cUGZg0K>2ijBk_2=3mtn0mjNgtB&$l`Yd1JC<gjf{lo zZwz+oR0X#FsyUi>bBVrXY^~`eojc#5`{)j30%hFH*u+JUzkl4bnWbE1rd(dhP#U+* zDM{Lt^F?|R(VQwv7lC1xi$sRyS^aX^EsE&yY-oTDuDaX)s!qA54=gj$f>dvAVWBIf zRv`iAv3c%BYJPA0^K73NLWBGt>w=u}u;RYo^Y@pK3<y&(Pl@us)dq_1U*-%2gWMZx zFVl%;`U|x%C=rno!>%|12T1c=8?;Cp-T<2Q<mkT87#5F&Pl(6$&6}S~b+5oqoGgqY zN1|X|EUJWFwE*p(@*NzI!<;*FyWN#YB?{FmA^KcCOYk>_UG2`WawdBEnU%N+DS`b4 zyDn4aUnI3O-;95I!c8+~IdYl(T^q*aBn;Glr@&a8#{PMYAK`g)<VV-|_UqTLo;*AG z=V!+c&6y<KSLTWEh-nCji96eyRoD`E&GIa)+dTd8{ryE8JTfO&XURz2YlMVvT`z7i z13dK~9y~lHq<kTPfiiN^J(sDYrLC>Xb}tXsHZ=5NPs1r3os?9T9?42a*YoSw@T41u zcIk^Jiv^al(;Yl-{c(wwYmZxH0+^Tss*m29Ch(^3&))RfT?}_Mb8&Sh6J@YF-j&QA z6J0BRX=l{#tZCIl>($xT+Uj~ZpRAk*fa#LF&^=mOQJ3B?q8IT9wa&A%uIA<(64ApH zK6IYYg{pVvgVy4V-ork&UoXJQ)b&`J^2^Gdn`2Wmm1lwn2Y001@oEWGJD5-Pm7Zk~ zD-ep(a#_G$fGTxS_4m)8d3Wpx1C3JL>L&w8>uYxQ6RwMS_<cP9GZ5;P%S|bsN3-c9 zBjr|5us16~NvY7SgyfuM#>0bpC*|XwP@0jLgJ8;&zR-y!zKZ*<g>q{nxEP%hLu@>9 zB`V^j?Q^~6?tgt(CQc^sw!8DSznj}2V8ZY;BAVyjRceK_UgvY+k~BY%$brE&v4ow` zF_3mHtJO_S%xiI{)uERr(}&C{&e{nt507|bNDe1UbA3ml&e_oyPwSK{JBz=DKCsX? zjZaRtoLz$JC*FwG-I*Wx;aa62Oh*@!;_muYNnh0cKsJG|Fi;ca+|s+bxgx<4?C&2= zHaMB~kT>tH-ql>IEJwY)N2%v~+wb{-)jbM|xuvD4<z>z7dnda~Yk7UXth|g4gR8of ze7n@orM##n;&_bsEJk_}x+z(*7u4nby%V~K;emY2a(`|*nft4fu&}W9_IGI98<ND! z120}Kk*xH&u5@5%V+ABwMy%{Eo+KLGGc+{Z-)`d)IqK&L72Vn$n=mw6nYfrt@9r+{ z5;<$uOF;sXQJG2p^t42tQ@19qaJ9FxFDrP(vg@|kg@t$KY2<rpS9o$b%Ti)+a4-nx zR%%!BhW^r0yM@k}JP+x3^yX;^;3&(@qr<{BkH|A)bBV9w)nRsFAtt*qMB8?K1Z~vR zsUzsP8s$w-_w2h-K23_p@mA`mTM&mP)O&HiX7Lm5N0YIjR5?CyTPWn;)$B~DeRZ<{ zR0DEglg}vxgcFAbYBiIbfGK#n`x_Rj+f|@i1#@&P6ZnqGgP~Cn|D+c-w!_=^e(@_n z410fWfvWd(2XSPZ%Zb@jqL;Lq=bzYH_vjKQkz+G0v@%@Q#h9(*8{em-%-4#Gjm<Gu zx`TF=P7R>F?<o27882L!S5&v97Twnp(?Lz4ry6mxk+Q0jKgf}wLrBDUjl&kh73k&D zXL(eYq~+zUhAJu<Sd=V>fA|WOn-18{Z8JhDb*k;9lUFbZL{!W6J+?WQ(?gqWc9x+K zi-cQUiARSsT~R+kl6dxvm!;;Dl75QUe#=rF&xY5gVqcc5K&jQi&@M9|2R%>E6O<VJ zN1axwoF?A*1lF7%Q@SjIZOSEC*bc&y-?~@;WnC^<v#l8(8j^B_hsS9g)_nOrAM$sx zfWzuP&dY~A-rg(+<X>`q@Q6-#BSUvbv}-**y2GZnOPs8Wt%lPir1?hE3`Qdo$0D+> z5zW}U4*J%+deZTmKWF+@2KSaG5Qyw-N#28H$eowDaVgniKHRwK^j}FL9gvMH4BC-R z1sb)i2D~=YhfOpd6|uOWK3{=Db)k^Aj;mc0UMoHMUG$-HR#O|a|NdL~vF9cU2}$O2 zQ#Uu!)3EcPjer0B7v%rZELp&KYAy{53J5%zOT|?;5Rq?{-bnUo6^*Q*bouHaiK}jF z3V2j;8V;>PT2UL@v#-Hc=p)p)$Fm9d6KdX4PwSCej6&Z*czm$*Tq$8@X69$eAd`N2 zxgJwl6?XmuWCbB1VP?U&XMRSmY_2)B3_Cbj=8zu^i7U{mt&yJjy}Wi4+0cLxeEITT z$sj-<BS?$U;%`<{61pD3JE5W%tcE&hI=6C_e6<b@(Thj_wuACaGK6XzR2G?0@y|_8 zhqI59KJ2fFKDsgJeSQXWpL&@&t+G$V7Wgd&mTLna_rMS4X+)Ly{Ufrot4tzvSj-T! zQFCy9>f&X*;<P%3baxTEcq=2*wiGI?*-%&rLl%4y|Lg2j>kriI-mg;%YCqZ`|NOa= zm{Q>9@854(B4rcGnj7mrGX)1~tVGI-l$U)MvDX7__Q55hQZg2B(J>n=yjUxrfRhTk z?I$waGIg129pKG_dhG;b3JMGFrSIwz(LIlavG7mCbQ|*&2nyB#DpCsV>fqxeK3F)h zc}5~Cymj~iQZGy(PUHCzHKNyWXp@}Gxc2;TDLZ9!badi}27u{T<V9UaztCerL9J&@ zfQ{ry=7@Fo=x_cPqS0}B+}4x+c3G8}>UrrQlJxG~!1!To=U~~Pba&<_C2msOv(1^V z3%3jLqYPS)Lr+AqFpbBmjsIU|=GgrmdC^kyDZ~TXJ%-8-@xPEu@RG<rfaZ1|v(9uC zuNUL6adNJMR-SL~$PdJ98(i=i?R$R`@9pc;^_t-zAvv(rsS0`EVr6e%%3i2dzAk80 ztFaw?afETdDyY1<zXQr=At)$l8gCG5Xy=(wWp(ih)D9%HA}TCwW@c<|Zh~kCPsy~v zKa5iD@F*2DYRaVgJic6SZ<+U3L2^`LK~&vbTTF`cb{zU-pWmd{dA}efp4WmEuEl3{ zco^ALb9|g2;(=EUvZcJpmh)87g;k66<E}XhQ%z0O(!lZn8{kXGEkDds3?`B3>7X4g z`WL^GbHQA9_Mk{<3sAk#)?PR+m-=N0oo!_U+7zLUW;MXWJMrDQuy=IiJlZ!p+fJ{l zI^jr=7RXwgy6#sixPJa*;OFOOW^Nj*u8tY~k)##!w!O8LwWbjR#_M!&bffjow$nr> z`vAw$ZhO*6Q)g1u9xHhJ{08)&fjBKP_o3$ICs;ZKt#b3o1prVf#wOXgYSoja#{dC< zDIDFzoZcBN-v~?J1tlQ*q*H6;h7alAXg~s-9q+r?A4f)fJOjy&M9BIgJyb{=P_z2C zXLrN6F#Y{1f-If}Y|^fo-1C30gy0w7>`IIqxWN4PpA^dm*fb7{y&`A+rT>a1+da)r z@t|Y=B!EVpvV)FBj+d^NtCRq&#rPs&;2rg4gyRTDe9HiwWwp{g4jU8GJfe1fbYwVL z*hxfGv{o)@#FdE8e9V4(mIWYM8Mgep%yO;1#kIA%Q)|s4Li>l2Y-$Cz_V!_fv!FO& zoh!bm6LTR$mKp@!XM+~eJT*2xJKpP$58B-o_Y0N!Ccoj<A75E};j+}r$nxc@Z_5tK zVWsi8Z(6gYGG?UA!;ftF`0!l<pDIC<d}7p0iT&{QmoK;R^WKI~oi~e<Jp5ZDV|(%( zOL(9Cc>jd9CYeQE6f0=B8!7+JYNXVEJ4LBV7h85z2}y?K2Jtm=ALFoz>YiOJXRJ0f zHl~-Ams>`zVCai<Ym2mIrl!^x7jvR|Q|lYA3F1}=Q8B}};u5x*Sl-H55LNOeKYnDa zuCJfh^`GoL)UI+|YYY|XsWI<PJ?}qZCLa&U%rtO5+yhEO$I%{nihx7Z_R&()#_wP> z6!F6dA!7QzeNj;|6n7O{E(-Q(%@6we(+il>a6j7qefF2Ov(2A;{7O%#JkMMYJIJ?v zrAz8~jArTPKr+w0F$gO2Siyr(FgzlHGfM!TK=aiKV>#3{G-Q(m3|gb4%`k;2M;%T( z<m^F#fm#)lTyvrh3AYLgR?S_SNVA}<7GD|J3@JJiEt!rk7~cFg`CZZ_GnwZx{QZMp zrJbRhi%TBY-N*>T&eMHRMuv<2<BOpd__*n|QNx&xR4T~o&}v3#5`fAgGEA!yUfTa0 zzdBPP{p}u4w){04ZrsdRHd22%uI)+1ohh|pad4COmOYnQpK<^ws}d3txdwK2f%+&l zom!fyd9}6ZyBsZWhmUVEvSYF%zlA(%X<>2OU*9{}$=4|PI{sL?fW5tAwA?Ct?CsXp z7PDO9YEQZw#oIUQqh7Cm2vtU71{oL_*kM0ZM3ORyB+yB-mPD7bOIs@oGqdE`aUK)_ zOBPGY{9(Ve{WbVu^iy=Nfb(Ye-hco7+ZddQ2vx*W|Mu;D;i#j6kI}QU?~>;S<ATd6 z-ZRW{oUrPT^$ovlxlKkxA3QTZ^_Us=x=2^fme1$i7N>UGi1nj!PX%;<1UitfPQZb? zu1bw#POZrI>+w;!+F!+2LF5axU?5LwYH3xWP^@(wKoA9C4(OgB)2s`@&*0;LyXDYC z!XL=gjBL~#xz09EMbLHuuCiyEkyL7>EQ)GtCRB8FEH^#PpQjdZ3!$P%@5;5JZ>y7t zK9`;5z<=SiK5_>5n9{((A{|5-)pP4t_-=ZITD4*XK#B0dBk3QMPk#Pg^1$*6CZrOy zwY3EX1Vk8tEH<z3mD9h7p^lrEcW-yM+5(GMfB0ME%VrzG5Tdt-p|u;ZDKY9wUKuYb zFO!cp2ITE-6HStk$!Az?D*%es7xj{DF^K+g<a@xndYx}2q*b~#1TDZ6SVu6ro+1v* zWY+*2nVFFR6cQjGP(<H6r3W&cpx`KAj>pGgfKPVwhKI!(TN+wgU2Lp6k(!_nYDp54 zl0peGs{h9YGzHP{+BA6l3L;W@{%p``;_MJpeKbFB=tsP&EQzuluik4)@ls?R*_EVS z8Z9rs+BtK!AJfwVNiZofu~jHRaGl$<zyv7#OG~+_yFtQrtnbcmn~zjy9tfi$<M((# zh8sH~)DdvH7Nm0Am-U{pJPrHjF&mpq8ouw&H>&k&9Kx$Por#H$kB^NtHZ@(Ino>M8 z{G20TYi9?<ubu7f#c(Li>6fe9a-2z3hek#q=KqqDXQvWG-hCx^>m`L0qSfd6J>HNh zpqiVQ0CgN57M7X{2?!1C>g<f}dnJc^XYQ5v)zG~%uyJtU<>8T$kqHbAHXu%6m8h{i z0vmDP-0iTwS7Jv`@viVZ4*Urhm;C1-@J2hk9iSFUCw`l>jERiA3v{6_z(j9n^5AP8 z{d<j^6qcrEX23fbo2_kZzW?~4WPQ-u-X4X}`Yc09NqL02r*iv`uwv;K6%_?0j|KDu zh*I76R@NgE64-RVGr*M`!S-KX4pk8Qv(_tQfx>+E?p=@`$vP|u7=P<q4`b<oUI^qf zUS8E!U`&vclLKubU?skB-Rg2xS69>*YH)*68MwZG4>tlYxa0H?Ulr)avxIOX7ZXcM zkdU8DWPJ{Jdp#y~UXLW`L2o^BnKUB2V^4iLHGaRXDLD0#I?w4S{Nk=bOiT;}4gyxZ zKVP{Zs$}Q_{rl%lGFT7y#!P@|1L#l;?omd^-KwmAx_a&gXf@LycuY*TNIuyEygn}} z<4h45<6m!}<wSqpgcI~|#^hSX)a(D#ZJhsq{?vXCCuamq7@$fr^b8D4-oO7={=DYZ zXEGrnA)vbUw}SEw0B@z@l9HX>-D}Yr?oXqRlZV>yI?PpDLv0_Pg^Ch!T42k3g!rt+ z(+Y(=1QZJ00oNBWY^!EMh72F~z_VW;Nr!HDUgaJY)!n+A-VmFu>CCb<L3p)CUAPZ0 zyU0pQceHeb$Al0QXJy^F_-(zvS_z67uoACAAn$U5x(~YPLv<=`RXO~=e$Dee5ItS= zOYu}|V_{)2eZNqI1yo#b(P1xP*TLGT7K`83U8c415-7pR!MbU0#`mjy@CbanK`u$a zV|~m8-3lnfxo=PkT3WBe&41V0Vuv1B43Cd{{`~K~lGM9*BQ>Dp(AL&=-CfM$#MW3y z>MKQ%F>L-QVT;w2@wgHv$Szmov9^vj0G0i=rlzJ~C6iH6triv(474V$t^$sq1$ZGq zt}}&}em{3gO^JzF7<*Zg`H%DbyO<*3%<kfE2u*s_LB+ysZlBWOYGkOpyZcqi@u~+b zEfvKW&N~b9^AI|w`M2e{fIzxrey6%bJ^OIg0j%v=&~<0@wW>Kc1zU#c9AK1Bb}66u zirvC%KeH&i&VcWLz?zo>w60&jek~2U?;pi%hXE>1WM?n|kq-y)xO{uraNckv8f;Of zne3`pc?%u{^3&h{g@lNJ=_FViDT}+nY?x*Wz~K-?F9uVtRv3@cdf~Y0?CDuU_WCtv zK5s5dE+D=wqxyRL8vy3EvDs28{!wN+Sekp)j3-}k2w&afHQar&XZYEuGx4S8bOsra z$ojf5Py!Ck!;Mj|$@5&2Xm>LJ+P46w&{w$BXO$}OMsdYnZ&KLfc%|@#$+ikoq1F?f z?niP}W_Z888ln`>?Qfc4e@PP+uTwf9_`YaXnj){h`IPvw>!pW>hrtPa`2b2F$SgN& z55_6Lww<P?TkF{fwyCET#@iYAE-o%gFYem!H;1Py8TV$`RFzruX957tY{lUVs^ymg zFQn=l8^y1p{=sYSJE;(@GIt3;mS~=z`=uQ3E@4X~omhaeAO{Xg4Z;_W1vvxaoVuK* zJs4e&Z}+q0+@y$#4TX04E50A(!J9%;6mAtbjk`8BHA!B*lPnkv54=ooO5ft8sOUw# z9}O1y!K@>mw-jDtc)?1NSpd>Kzr{#e^v2S_PV`z*QWAD-EJM$-bHeCy!YV!yGT^9U zd<n@zX^6UOfL90X6zGdjco@W)(@l0W0Jmh?(#pc%NgV&el}h3HYS|{1FNPW>y0BQC zO1S97p)7(4luDP4@y_c|x#69+JR>iv{Jau=Y&&vG85k}?sa_r*Q(7%7Eabye+)t<A zZRCNjr<+**U*;G6OMP6`hXI=FqvfGW+_|~A%d;A<w~>HJV<EencKOmeozTm)kpqw% zmsZ(pg7mi`>Obt`$R6@;d(2MZc>zWrsL+GB>OeAL%?cf8iOderjEaa5Sg-MqcxzHN zW(~M_Hb%RNL+Pr6RV27dN|YB`NP)=oC#_u0bsC7n7?`Unfw0@Y@nAvjLp}c(n7ils zX9*gb^|cYCVs?#YjeDk0RBuUnx!c@aOtQ!N9WAW{7q4?pE}07bY&mMiI!Ir)nq2&I zvFou2028D7e4)JZlToMCR4#K?iU4&^p_8Md=iD6mXqgE$ZHhvbLU#6l&CS5opsd`d z$!oUEVQX#u<dhR|fCU=qu4;~sz!k7}AALBz1L6XRsGp2J7r_Ykb~latbH1T+tWw5- zRNh@?@8GaDTHcl6KXWD2=j2_kZwWik6P@IQ=O$xS2g0Zu(ax(qegh3HcO@NGc6N4F zc#YX_s0H3X2a1dm<ZYQ%u_q|QwuP+DKl?|n$254%{662EaavAuTpKw@yZT-Q#v#}g zz4MHlC&k3Xj8fe7U8f|VMKB^lLN0Au;H2x60;*t{e70Xy5l{sN^4+dnN8SVy-gi=2 z0U^@>sM-lZL;EQSyZQDcfHQs-RGIYMcX^<_yCq3$ENJaN;Dw0WS|$8t4na4js@Lex z0mu+wa@5j9`(R89tOlGMf~&4Mm}1V?Fd85UK@1|?Svfh27&t)X5MTv-JGqzly;8mW zXu$H&cc1!W8f=w@y85DOr*836p;MG)2cSFS`7F{f1+pt@D6X}&HIL<f#enF<2vSN) zwmX7?f=>M9`oKpsbj9>2sDjF;PIkRAJ4UFK3)E>MhnpX+Y;rDu5YTl$8UU-uV$=x; zl^f?QkpaW~bvMrD6yN!{?2rp>&GG~o$~mnutY0<@M>vZBC-pBI8=LeE^hO|8t(vaq zaf=@y8P6Q{y8u57Jc&U4Sf7&2%Ff7Oy?nyE^$iI-&eKP%tfuFuzM@_69zaRf+&MNw z<n-l)J`iCS<pN<(3w%vlKkSErE05OGc*LhK3$+w<qrJ0}7{AiJ&B-6QDF}fBXrW8D z!r%nu;6XrQb!i>)uA%|O$=Jt<^IJfR?mH%;1U1gULpQf75>aQb)>;1%K6*)F{%6lz z41e4COG`g{_Keo!bSXx+gUV}Ss3}`?5bQ(GD;zmWJ(@Tfz4{zh_?u^<J8p(h6+jV1 zS8GP~Z}GowOfoUcCu^2CCdI?z;^W5wx*iu6bm(In05~e-4wdWSH@ptuh3z21TR1;o z01)<iE0ahE1?t$xhQpKdgL6M%TLaPWY(1g^TuEt~C=|+be@PM`nYG~Z@$piVHf4E{ zqK-;Xe|!{#NJMsA4WGhZEFizxa1?wmu_Hf4fogu1K&Yye)PJ*TmrmP>=5OMDqh%H! zOd;iJm*bt|3=+cC_0-h<y3oY&R;0fsn*X1ls+2cg$Hqoo(im$pUS6Wil9E?72M&%? zk|0+dL`KGKZI2&xC>wTGYN9;hwLuMhO8>hhTyTT)i*mMbsqLJpParUtJ~P_qx=Kkn zIO~s{)V%@Qxv%))xAZr_fpiI<1l882M9C+<INEEs0-!(R^kDfO7UihOTL1k+lNQo6 zcMP9Xw{luJJLGEEajc;gjW-LT3rC~{=zRe=2PpKN7VzwD`_Z_(BOo245_MG1H5R?# zKAD5Us;tI_;&i><8yIA&0f}HCTrxYxnh@m31pcDF{C6@kly(~)5RCzZ?#8H9&0u~a zU=bEz&P$o(DPAC*5hAvyBvURa+{wYyc>a^)e6GJa2?z;mtxg#-3+{6s<^y)<x^J)I z9_&%611uhRpG&98Q3rSws)GVUp?&!`&Q6xHp!&cZLrBgw{KFvs`QjMTFR-LU1t2D3 z3U23Bbh?hVcGR{BFx_TnH#JBEa%opr=#A6T(9kHEn7p4_pNE|W{8+e5xZk8BnS5h_ z#bONreU^NHLmJ?g>z1V7Jx*JU>g(wyy!iVX8eTh@NHx!;OWyk1QWlX}w7OaV0J@;y zOJD_(Ee!8M8m<XoAAUE(tbh6W^V6+kGk&C77wm}Cz~OolQwxI1@dh`%PWRe_76>rF z6K%B;9ZZ_EIMDR__x=+969$HiP1XqyK|7#=K#son`i7=FT>=Fg(Zhb-64y)s7GOS% zGBI)Su^}|VN?Lcx?Dyl0Qex^Bj}{E8cc*ve=Z|gP+u8;=CAorBM~OkOoQ5<?hR8u6 z=Q9T{$C0K>91++spP3DeJEqgZ7b@g=`qSeslf;`hfRFyziKtzy*=P)X7jp)*1`<4C zs?<@>?I=6}$JOHZ*ogqT7%pAQVU$N(G>4=4Xu%*b{W%ZhU{MbF7Fv9!3D~}DuUB>7 z*<!d%H~t#-nhXPU05!>r*_oO2>-dQB58tl?)F7gM*YuL#a62e$Sc0d;-81X^%8Njs zN8f(#8kp?9OyQ3P>PKpnI4Da75hrf#=z}#;YwOMKDYFY$X2N?AnCPwf(eQ9ZnHY|X z?Z7T=?L^Uw>3oEdr&hMynk?mDnMofimIL^n9wzFvwHHi|nt<|fwR$`)sJ&gE?}_xK zNQO)N5Ouu4D=&Pu8Y=33vJpT*0jYl_LyJ|2Q01`v9~ZFddgze(vDC(B>a!()^_|x} zsf(pB@!soK=IL)3;mt?cud6g5ftBWx#T6>$1(gMjh`xP2Zc4JvY`15oJ2)h8pG)(p ze*XhN$AFN=f+~Rqk7V_1;zA@5D4L*FsYIz&xgTwVdNi^^7BRP`I6CS!x1HJeHt3;< zdyb`twY3-tum3<)FEGn}kwGRLC*8SC)tCP-P^GWEcHRLWf^H0c_6_W>ETVBa{1%Z7 z#ob#Rw-_nS1)C?Z=k@kxwlxJ&WJZPh_?-R73~cXc2OHV!+}2%YdU|@K%*o!$4B*Fr zgbr~XC^U<y@o?J!sHUpaq>srL@0J=3u;>G^a%5=8*i0@-gxY!j(pU?026%6n(0ZS| z2)pT08=^WnCFP?2WJ5ze7HId^nMFbE_X9nk0cs}$xB^rajm$Ba>y!(AaYnKAR>U@* z`fDeg8K`cDHgaUMT9H%-MD_Fvq9)e$;^Q1&uPmasw>Q~q%78`ag+<=D``R*t2AFyY zi>96)u?8Tjk&@m8hA3V^!764NuXk5fE>6yE@W~`WC%I%we$&Nu*sSpRIjqDHm;k}# zMB4(xUADYesaI_cu9mBd%Q3H&=ko3n;R4qa_=n#?*(;c;XR@lcw%cZiT8(VSHFDUY z6EOm5hOro`$juqx;pQF_G9@eOYH0~y*r%?Fjg4L8P_k;vkresx`gNM6EU?To;;B66 z=@#WX^B4Cc29m;I3o{KytEUT5348)lQc_)R$AkO((ecuEnRiz(k{<LM0(<bZT(^k$ zc+I|(`)CwrvS>PG&UJ61!k*!(18hV@sO14%kRYxc7Q1V#eVisvCc&yV)m<N|l%d%G z%cxIYSg~U3{1zVLaQN#LzQQPOe~y2$wPt|rba64&gJn3*@$2E@qA`APUZrXR;MU6O z2lAnT_{Com5<ZDJf^h}H4=G1nLc$UUkRO1lp)cS4Y_HsLZ9HA;uJf`p2+5Bzz_%72 z7P|;URUqR46uPHYT3qaWlu2HbeAKZu!#qpJn{+`@$uc}#VROJLA?US!nv6bK#vm~1 zl1`cGpltb*+m|Rlc=OK0Wvdlwa`?vRdU!OIb-2o43MW!!5vKh)od0(1t^BLxxw7UY zSZW1*vFW<?3)<gM!jr7n`mxih)@yUB3B@aVc8CSlr>QO^I)mDaStCYh;iJ(?NVANr zEQjF<upb+BC9-PkmV2gO0<;wjSq<U04BK0HL<9M&K#T{M=6KDA?0s3_6P|F9{85P2 z^*E~R6D8Ue+Ft}@`*t^Q@%{KwGHc+##f603EZ`EoP+=KyyEp+dw_8)tWqtl91|fPu zH`G9S1iSNh_oLRCCizQIraxENKE_iR94XkGr$h)}XxDcTh#nt;kU7&Pn7G)Cgn9EC zb&mCY`lR&0ZQ}uO5<=n*`HY_$PvRim*H4cCd$9NLU7~$0tfNIao06AhW)wc0kEqVz zC^x|<I*DeFiylSySxKwSt&FC|;sP@MbjmMfk5yb;eD0{LcD_)fq-c|sMELm{pZv-7 zPP8rp;=|u*+*A1`I}6x{b-3yX2nZq;z7ly1>_U2O^8;}M<+`R>_x*$GX!yWDLxNRJ zR@7znHTy04(t^-cdk8_)ht>OlBPj#G4E-DEt)Qy?N^(-}*Wcrf<6u1Ql_OvK2{7o@ z1|``kuCF7+d;)>xjgctI`8yDcM<6~Qgy3Exw(s5r{YeudZy#Rzi^!494Gl`kg}io= zW-nimwMzkeeJ17wKR=Y?LE>I@n$Xrydo1w6*5&Dpo&<J@_V67}Hilm8;K+n{QxWaG zc#s$Zx!>|WCVRTP4`G#pfq7_D!bwDrXGJfXuhRGAx?aYO!Kw{3;y%i0Bd@#};Zl-R z<(MMma6G!<>Ac+XnR40V{1+6|1yAmLAUWH6c(6Jwo+NzcLn3lMw?Ioq9~q<j53{tb z{p!$GgN&UW;ri<*v>Yrf(pEi3y%UNyqtU_m`1l=XT7YW2L-o?w!S3c0Tq2q>pi6?P z;9hM~Ev5iXMpZD@8pk8{Cln-}{>E83^p9LV6a&UwKU!A>tK2VYq<Yd*9p&S|45|C0 z#4at(@?8wfIes6w!p^7L5Mb$&b7=^G0J=mcDxbsC*9Z1ZlukmVAF1|L6_tspsYTb{ zL-^@JWA>MIKAJzDJ2!#cu>p{BvED?t>(p@{QG2NkqXN|m)ESplft~i!#&h}>Xdr9m z7I3Zb{{<>&UDVX*Mh%pZr}q5EX0M2cYc22sZB(C#viSM>nyu6xo<?>i2`uvhV)cBv zt&2%EnRSk<Hu>dAOiC1Fa${p*dbJ?B<VQ)l)x^}|WcOBznD2DMuRu^*`9l9DX34P! zqlrOSO=Yq{3xOqtrn@#p;FS{8{#|<rwIIAVa%?#}#mM7mpuHuMNkCwH$lTTSIKirx z-gg1;IMG@Uo~C-?`~8zF<e+(-GoSE(_`8^xR^V|L$LZNIWq`xaAfx1EY!whrPpd6X z94RSBlZC59_kPX*za`I*Gr*`F3A}>T51ePEyp>*#dn|B(MdnV)n?I3y4$@pH5kPS> zT_XJd{Lal)J6OjWo^5}a%G4^e3=9pum^?cm@G3pSBaY^%vvqRv+V)>c@!A?VBN3`_ zo)RJ?3<PIBOh`mXA3hueTPqN!Cu|8<1}y*(+Kujz3%@?J<xQXu(npd25&^4noStQA zaatb8H!BVXC|LA-(?Yt;@o3vX8j&%noL#m}%VH|$io{b}8RUSsk^vsunJEXzF3W9T zy}x|2xH{snLhG`+HC%hT`!lkuV50bYS@}Vtt`|$opslSPH#ZNY_kVlkUmzC<?{=4B z-S#0<1DNsdwI&rl5a0ohC9P`?v&}ugFkgLIeTmiD;(1LMc@^CBz+ps4?sGaiy7b>; zm}(7W3vX~TOkgF37LSOcM*mkHutjreR~TDam0Q>AuCK2%4o?<;6NkL`vbj2J_h-ph zUS@yyp>AO0rxvhxp2_L}xT<y`83xg4xnE|9W%4c35g?%ul440IF5V2L<d=Ehl?=@9 z<7WE$X=BS+Bt4#)o7=(ifVh*1ic0_a8D7-q1m-dY)+XSZSxEL`0cw~@<Zsj-Sd%I4 zyJD9nb96+^M)jFtOLqB0wLyM{ZMnJQ?4shI^8E3GrNZeNu#$lzHM}9`*^K>4(**yz z&%uNP&_ycL?Cp0!MS%Rc^rInA<ZPBc)15(qQq%@wZe9>|5nul5bz+L=X$`cChLf{1 znTBS>FoH<<V(f2Z$-r#OT@UwVsc$f}EA>b@4=?WkcG3xmt8sBGE#W1RkxCGXrKMl| z;Ji`f0$!GIVnV`6d!KyG-u%jl1W+h1IRha0vjZt}2ON`mY5e{@#-JROZ&6wRq~nv2 za7pw0-#(al&jG!vYNL8@Ul5RZT<R5z-BXQ%WfnHp#Tc!qu-GB1I&kpu7XOLch1W)O zwO_axX|Qbkws!`U$52Svp!X+0Y9?!_J&$9eL&tgO!Gx$rIa@R!0u!vNv!cO}1NZj$ zB<{@2%)%)jlz>a*B2mdTq1+s9gjj~*#^Bzj0;TK^GB7H&7wZYY2$r`8sDMCnuA|)& zuDO|J5z!ZGpMcNZ;8%dERqe?*aD-_WTTcOF!gI^j2b7cv`s1#%|4lJcy*^wr(!CaX zapnv&03|iQ#0<n)r^&icb_<+943{;Xo}Tgf+zCUGoz?=L9>A8p5%9_rQ&&a}IKYBr zz-<9az@c2_CSvLlwz)7+o;H7we<LA*<+ofFv_jMI%8+w88~)zende&M*DD4GZPUNj zBqt|>1DJ*Y4;s#AWijPdS63${f|EyMV~L4C-EJ>vkLJ*9UWnj0J#AZ!it4|A?n(dl zZ?-8*A-=N~07pmEwIn4ZQ$)SQSYks=bLyjkU4DCYWmIoGrIz1(F(DzL%Ht&Dh>*Nc zr%D8zYr7A?`}|Iqh{<q+ZYyASsGp`@>~K|=dN%&1=n}c;th%W3YR$l-b$`{%uYm&l zh&UceZUVN-t5%3s$a!%EQCm4TPSan2beizA<KaPOIV22Xs~l9X;R>WBBZqR9A{&5v zof=yS0<&}2FjB_I;tZr#$siPz0325pcHB)>`bJFk;)Pzf0>1tJ#^~iSvXiWdi^INf z`Wpa%GmiCNjJqx5GSkL1T)Iz2R;)O6Q8}>`HLeHLVX?6l=1XY;NW%4$!%nvivq*X) zqlItcxaf;>H@T1yT#Uzt7p;)s3<WYJzJPIsHU5SV(Df69T=Z*eJP70`kwzOe0KipA z^p(r#BW|5I0`vw^JNy-+%XUvpq{98$?1PgU1q?%Gqh)K$1?m7qk-SS%N`D)in7Epg zv#7h!e(Bphu7x5N2a$nFJ@+|a)9dqUDnn${)POz(2MD6V<KmcYRb7q(&c9n=^D_4h zy+|J{8CsWvKT%Oo<VFL-T=?_6FKnPj9T(lJ?@SU5uC-d_u;O(Vc&eaZ08YQ;qBDW6 zJ~It@*mbW97<xN8{+u*?k}1euNBaNG(7?RmQBgvs?L+jEA<F~h7Ndqoy}v?2h_AEZ zC5Hp^&NMbuB_Srpul3Z?QZ9KLe963Zty~@2!|@ix0zc#R?v}dz^v~Sl(EkxCad6WA z-=CZq=u_&$@b6zfq4KE2Y1~Efl5JPf>qZf2b#$}~36BMul-V7e^RDoZ4iCFlRIy}+ zM?|o)#mB{g<1~3Hi8+h08=j}Ui>tm&!S3LYYZP}&OH16&$!G7GY|S!b*p!n^Qmyfk zFJV1zo*Qr-<n!iZ%!iyN|BK14@<F4^hO;;X9oJqebPNo)v?#I!g933)@St@WY_!in zIR}w1+uT8TX;+htmD-3aE1!9FULL%dTA1ies$JZ`ltQr6`=`Gq8QawGlB&8J5OGSb zCnPj4A6hqnqxAs&<wlg5kKVIh&8SL9IIVnAts*xvV8+*KrRMpt)ys4+v3Ag6NC{zS z!QAxN%N6swvF6Bg|A;VVq;#9h)2yBjs&<YR^W^0<Q^9DJe!z&lWK!ZX^3}8xer($l zq3SQ);^1MbdwRC?KQ4gn9>=Nc&v5U%q^VUEF96j$xvms_(L;jc6r7PkRw-q+sI_sw zBO-EUdfLv;7Oo`V$-QYmIb6rHfJ?M!*A`eu8Zl`y9KfrgF`P_eSysje_Bq~U?Nt_E zR~e6!#?{rt#Ayt*psRCX{6L^4>(NN&WLw0^Ze?~=*|zIPm5(3o-@eU)$-F!_S)zcj zNHaaSN6nE>`@%lM=ztTl<oi`8G$e$VKXSLXV6!)@4LWm|l+h<gX$iiWyOtE4%!#L_ z^mQ4QNvuIkE1Vn_Hk6l{QlD9Sfy_Bfc4_n@wzhHXtr;se(R%aIUNM+amgaeCxhAZ_ zS>ml>biJWC-rm6Prhmf~?+x&GPu7P|WHB@V?wORJstU?B_6Qsee8$285*f>lm$BQ~ z;w<^&Xp}1^Pouy0lrmuSGdtRWKDe~h{)i4!Fs16O=>GciX;Mr3@xf?Idwaw~DCY4k zCj2ZOi1mL0PR1+qSzciX$v11`ZjZPSI%t#wV0W#Zz!^&Kgo0x?@sFkFyd~O^Z95^@ z6JjcnJS{6z{p0RicLRqtF`a8bL7H_nUpwxwHGt7!R4DnaLPPvj2{^y<oWsg<a}1M# zKr>pV1mSwgs9Mx$=7!;}P|>Hy|7L3!*tShfLmak{6-*gjrmLE}yVAF!v<biE7vg9P z{z*huzt}iDA2T~#E~eun!{{BkfD&<9;N6UwIE{kY8|QddmUtyVD?Y?W2M6!j$%|$| zS>j?&l|Cw3ST$C*!1KRBzW*rs;h+m~b=cDk&!3%}Gik##Gz1>MLG`pGPLR)zu_khS z<A=GFZEn51YY*#XJIdtG(S<r4l=eTBN+#kQ7}axkfBlU1B6`<$s=L^_Wuuf!xvzKj zXtk11u}9+V7aJRV(>D4WO-MK_nFeV(s1V?=JJbSBAt2q)Tr-4d0oWRsDU^ml)SgR6 zp1xNxV_;ezt@3l+>oe-i0`fumG^W7+w)(cD%Sk8tV0U#SSNxKG9vc7}$|MuMKcMq% zT;a<S2yNvh9kqLQzKDVE9zX%uaiA<?*Q%Tk$73ev(aUD|+?gB`C=Re|L#DpwF<%a} z3`d67Vf%&8-VQlOtusqT?7hC;UioA|G>5?5g*7?`Bt%kgtGgZ04z4Y$zBY}0Eap?6 zxolK0L?^evr7d7Tt8U$V4`0|!<$yI$!`^=NYsctoPF;uMa?IMV4`v@`)^tkb)--XO zzFT5zWJo_P!;;Z%4$78{P;oxk*K-5Xnr51Z@18Q&C_aDxbJ`%^VFioLldegP1{wlQ ztgY^pLkvQboSYo-fP1T^$Mseppp?9h_PQE4vazD$YRs+L6(0@Wt;*IKY-sY@LMB3$ zq&~8@L}~FtEmTZ5yQ%SC&~;gi0uw5i;kmj>R++<zr3+b+*K`jaK`3u@<k$ov7nVt7 zif_4$*;yKBe09(W1}ra$%^^wHNl~pQKPn~EmJO77Kg8DBI;lso+;tJ6%C^ZXgST;) zoERg&$WYoY-BDL&3h8L;XanX2vVp-t!`3g~P0#AVY=@*oP;XkntY2ws69}xd;IkNg zfYC&*-@vgx04juc!kDOu`BW?LKff(<)AiU|`a$%>Y!oFS)eKLyBj>Xm5pis{&MbKR zIK7_}YI~-37_WAZoVM0;hOaWmtPkb8t^2}t^^3MVh-MROA;lJOFyww;?K^npk~V!V zaJDt;$4h^GVw0@ddD4XJwgv*QJc*M<3{{I-{|3K^2%QL>qP}E{U{rwJrnUD^C8fV! zIR1#mVWZ~}w*Ug#V-l=Hc2^?Y2tRo?0%jGExw#U)tjTz=c`K4Yo;DiP$6~Sa)EjAo z5siCO**rv?0_pfH4D~`z=V_Mce&aUVFGQY>gn^GQ@cc6~echE&yn_^KxLT+ef-5xl zdF;;WzeO^S<wvzN{9^0W{X6<Wkq!Ms+Q=J;U%>a-MrSdQrguBDu<+AXHb~00I2;5I z4y&2I-n7BP^J|b~f4+)JTf#Hi)YH_^xL;HNEf1lRUTbTV49(8ULYnqe$67{!f8fz1 zml!QRB6qhX*D?;qatBTC1{u$ec3OM+=>1^bIa*lQ@;1Wuv&C4oPVW>AR^Y7Uw=7<( zY~-|o5&7xUj&~hG=Ndh7Jo+Q`^)M}P06mZF(t*MZjYaWnG{a-H9f7Iii|<5#ZvDWo z%2{OvBATItR%Ca{0vDuJq0Cd5oXeM8;~eP3^$*yFr9#(}9E=E+7#WxfVI_*aY?a>T zLv@=!Veh9f1M3o%mHfERTJB}#=K6yJA(o*WKEm2jpS{_|SmJMs+rDS_m7^YcG8Y*K zS8aInwgxe+#b?p0pam<npYsi*8@VTXyh^#2X7k}ID5zf@Wt%0ipG`(0h%6P9e5U<E z?ng&O6<}NHQtfUXW9`~17YvL_@ik%Kuq*z13`pm1BqW>8&oOB%cbiO&eic2jod>SZ zhS;GO_^!+S=T3j;GALB-uWs}dmM}0$6uM367iV}zum?ThR<H`Uw6rBxuF^f?w@UkM zAun|NYKm`#&ph0abj*HzNUjpiP}fV{;J!a5fnTh7$j@b3on7I|@$Tm3KSf#B5u`AG zRBz+%UcYI2l>fNvyN!)0mr#p3@<Z5wlwySb8YL*{GHP;cT0x<N3#8kefKr~jfQS0> z!d5I(PCA_!KPp47b7N;uEga`H!gev$Q$kivC-#jF+RgFA4GSb^Cz0*RkC{<7Lz1+s z-UqJQMZT@!HC35Fd0=RF?u$M>62ZEm8JQTR4U}wcM+Z3s%DA{i35cp{><&D!@mM#K zCwFFS5*=<8q`k)TM@6Nr30l8kWK@md<;53pJnbK(=Kl)GZOpyTrDb8bZ^eJtLGD1+ z3!%Hy^=_Fb+5L^IV_qdnNXSBIs{Do1dBhi%8lvUB)o~4$xQ0f)xoHi4Q6McUSGhfj z_oQ8xVlz>ws7*>_1y)dlML(x)`}$%CzA)nrU{i~b?<p`zpN1~=w$%L};@<Kv%JqBy z#)6FqZWTmYrKF`xzyujex=WB|=uSmMKstsHP+;hi4go=>ySqCE9J+rC_vd^63pY>n zCE(O`o##5&I*#|DmaF*o33&2RpCrS}@zSH&pZA(AY3lCQ{rWYx^eqBQQoL!NWu(9I zGS7to#imob(v6p%VXSJ4Xi3_NZW3;>GVJme#Zs8|X8BGm-!o1-d@<RZl8r+7DSV|9 z81NL%3YQMGk8h?4x|Mzr-IXUZ+_*krGq(}$_`FEr-`5{9mJ19MMkbW8s%U+mL!{6% zT3E7!s5w&=7@OcGaiWKtL^<sJlM6zF+M`aGFh62(BAPkazBJTx<IGXA>x6lpyKCUJ z@QG3g7;L*tbAwEIL{@Foac#J0<4ZExRmOvNsn>QLOS1=&#}1fd&Xf-s{Nw~g6@?b_ zBiq~C(WmXWyBpeV1MX&SdmE!t-O}%i-;A;|HSy6$by1*UH}k+%<uD>*#V0S_7|S+6 zcBK&|wsbUiQ;DXTh-Z-Ka4IgI+KdP%N_rY@id(HrF*T>QScg1ftMDA4qAzprAL)H- z+>d?xOoE;!Q@QA{_=u5MhZD-nwaJC$AZKR(TX8OR8fDm!5V=RN{Jy6uyH;>5d<&GL zj(Zb6w#BWEDUUhbn{Zy->bD#&<>u$lmti?8FW<Kwj!Ty~aTOh}at!}yeURm_Cr8dw z(MGd{0^M@U1ksXHNHV?)%$`!p>J-y`l9ON0eUaozQ{q`ynryJ>k0S(GBAk7I;W5$z z3*G8?JqH)Fcw)Xbfv{4>nYN2j4hy&7`m>+)ff{k4p{c~#U#%|T`Mrx&l>Kc7E7C-k zEVF95WA4X~8E~pF*Tjf9r)C6wq85B!#+{iuq5AA+q!+`oTxV0LK{mU>q7N!UCLw5I zph4Ve0XQtygRo(}A{kcG6a*<noMa@O2<SzL5p~ZWo$a!GD=Uh4*fg~QN6|O`6m%SO z2mPu0c>#U3pszWXUbIVRrgYQF$kL$N{$SGXOsj7(5o0_59m@L5jqz+v`LFTOT6)=o zLxs`gdd)_#@ke6AXe-?3$*Ws;N^>=fx2^J`7mr27n1zgvhr%4p&f+QAv<hdiQP2*! zK7dQ}yUjOM0Z(2-UAZNLM-pd`N+8uFxU>#;1&`J8IxBvB-$7P9zvbspwSV=ruhszI zC5=Bs8K2i`;Q2*XhoX7OKe9=JER3~DMopv5y~cbNBMdf=)6MckDjfnoe$3G<3trnw z+Cnj;rxsguyTcL_PTrI$Y^bMOT^ADm9yzJ}uh#C;fI5F*9~VD=Z=xP_mSK*pJk5u@ z(UKm%)>b#6Qzrhp%YadqWxl|9*lb5U;)RRJ%JXru?LbDq7qYYUJ2$JzKChpS`AYe2 z{D2!y%13d{63V5ZL5EB~sToFxro=il{Z}`)I<8I2SE|(^ivh~3k?)qjWjSq($ZOlr zn`NX{!!+ghHG3HZBD$7Bkl!gkVteIR^W$|XL4A8&=D2^=6Ot#oyWfJO&zsrzT+h^3 zrRwDJQh($0r>)0OiJf14g`C>{j+Ix8rS;DCr)%iQ`?M*uGq<2F^8XYd>NOo6sjdB4 zsvNWV^^#^8b}*q<oCljL21OacZH~$B4(Z9u`8;O7rW)ejx{=KI=`N7x$Vu~zF&T`1 z$TcJ7)&^x^bTmKiwK$Uo>K4zHU{S?J3qa?f+~eqYe<)mPyvkS?`ut(81`mf>_>R}b zoE~~LMqZy*Gq=HTDa<=RpJxiTg@)n@E%qtm>#>T7VfNT3MAYo%m$X--PhA7&dV{K* zeodz|8h0k3qN3Oi(#RL#9TXOrLpVBiRHuAcDV2NqGBQS-YdCc-Qm$sdbkl9wC_lic z@VMSGpjpdMyCnWAMmU=LN4VN^S-NJGHiC6&TZCDi_BvK7?t)~S@n-ZpW_&}u>r2@l zE;CAcP4n3|n-2!goB}F$;!DZaVl$Zk86-FnGF+U09;F+h(<ljw<_~pKIS=u@uTAuU z)3E4p5$$aIu+uDU9Zo&{ny@0;a8f-*<!aq>7C*zhAFr~v=*^n?9O>>jyRZ&|029S5 z+6#=bi9)?!warWRULP1Z!F_0a&a3%pk(28Y7=tImv*tQo)knI~HvBI5=BcL)bU&Qd zVZc8X_T<uGq_uVh?toN2oku9~+SD}8`q*PzLYKKEn_KPM;%};ylcTk|7UmX+OO&b| zs&&LNVbP%r4%>@z8mzjc?W?%hla-1il`P4^((2~w$dXkjrrn0Hc~6gcwLv}fUEQEW z{3@LBqNV;6UQug4n^U2$^?R%j|KcAIkcpYk6jrZ7teokT$<JImc<N@q+LtmKRMF7z zi4waycJRcw(`uwJMK>vNWVb)7LiEp4aq;$cOpKaj+}u`6{xhe`89uT!l$l;Tztage zIy-AmZ)Dmnz8^K%{&>$<>~zD6t<siPiHS^*MkPmwqgGS^g*p1aUckZBAkU@A?Uzl- zZa=|}YmHwp*R-U99InZZ!O7M{1#1f}ckX*GHGLzDFXct{>Gf^a`Klh+ord3>!1-zv zwDMbeCU2Cs<jx|;<?L_R^Mk(I%0%JAdz_MPo$u?fF%&=xnLX~K%867@hW@2>Y3oJa z@d5BDAZ!_*!w0nY_XE<>)(#+-DD_xORKQbvvN+dMZSxcNrr`Rx*V2CTkHqn+$a~(v zO%igPiPWlLFsT&o&%pGf?cD6Ri$soyuJ~z%`2ki43KEU7ZI1qid7($VyfRd4iX%s> z&5RmQ$s{kE(1uBX$=ythL+~QJpQMw88K&O@I5WI9J`k?JUeWNI5o9357(evH<6I^D zY2U!Qn?PEL<2zb3`e^1&LPo+C7KDkEq$fV^94dQ#*g?#}$L{H8)Ri=0p>p$=#dwY8 zu1p8s!P+z2U1eiKR1y2selr}vW$*Bt?T#Jb5mD`6x4VM$=yoDN+7n?|BPrRe>$Mjc zk@EABBEsW%L*VB9)})IEQz;VHqUCaxB-1bX%=vLFemZUsv+k`jB3yrdYKzr<aAIQK zUqNt%Wgvf5(Ve@@EU!I|2YeLJMEfxwd@ex_{1k9#UZ>y_NH_ip@r;Yg{~9;|4Zn8R z*Q)>c7ICvKa#IwP0xr{owE0?u)vj#Yurrt$`&EjTR5@b4BuGiU^T{5p--Qit`gKz& zm7C+*2%~CPNwvA_V7LS!Mf^;Cj^bP6y*1$>Sb9y9;x&816cJC{7nmwO`TAD)_;~oZ ziWd69y%>o|!v(z){vY)5ZF~5PWD&9hHPZXWn<?(XGUWV`CZ@YqTXbPAh9idKM~|4# zwtpx{h(?DBdAOapIuuX7OBJ*2r@BSq1nY-bXClHBY^IjOIuGq)BO(l@C!Wz0lag{w zYDjsVgoWFVG~pE&7OsqEo5!oL6>A?P8@%SX8Yd$s_uN?0hkY-u%zAi)Fn<1Ww#vF= zD(=yBO2Ic?DrF4ZEQ*{&wmEa3$(%O+0nP6`5uF#O>}OWn;H=0#%@FyhsiS&yC^QW_ zKZ{I$H=Q;AIxVGooLk+UbsQJ4Z1jLB%MgQ0noxU(^42XDc`Vzl32{OSo*ptG?fP#K z&{vav4s!PwJ@MgMPT;P-DQ4L%9oBoCbVT83HYt8F2MQC1<-VGI!B)8>LASMirV56( z<_gX<xx5Gp-kiyA9`W@FK6IX0>mrDH)HA<J?6|toC39aN_>sskUQtIoWXBr`BP3-M z39@<l4}dIiU|v_ay={;FmFRZJS<*Y;T~ob{E2u)vYvH=|*8jx|T-zMaxE;4YTeEA! zP8l(1XdyuhQ`Cmby1PgDx^#t{Q-`fXR$4fon(d>0>Id!deBHUIbtw&d=7%q<b5rXa z=)?LFO%4uR@bM+#AQnO`%ygt@Y02xjUBDGW5>>wCFOlASFPvNJemA)9IzLx`aY4D) z8p#~)ENAjpxqmVGClzJ%QV+_y1mV(Ar>TNRABZrzSpIg7_I&ZdVFadEl{(0MbyQ8$ zrMwD2Zxj}HT@YOr1S6<#aTHuH?`CLbo;XZ;{{FPV^3_t<Q7W4GM)+m56)I->W9=fO zUHkxB<U39Mp@A&*>I?_Vz<&m4?<Ys+s->7PuqYw!vVQ*}mUGO;D=*z)MwC<3VM%xa zRMzLdqH9~*n=y~3b$kM$22Pp4&7->*oD>igWM`MXkX=#tJvo*;<Ez!l7AZTGfPJwh zis*Na`5RL*+k-5v+lPIB@>UhoJ*$QKs~-i1u+h288~8#28_J^k#zFGI>#L4a;>NM% z`|rOLk<M|+_553sCP_O9=Vv$PayUp5*AR)h97>Ce8)e^aCy@J?k{k9aWiv4qjb7D3 zw@SU>PJLN+rlFwlgg-;Co$a8HoPuH%mKEj~zzK`a_Gq3Bh%?mg9=k4Iq1-^Ho`|74 zyh*n#^Ml9vaZ*M`%<7FWul#Wy+x@$i)g#Yx=PnFn{wNCPm%Kc%v~aLP-yVr0CA#xl z3e|s9PLei3Tli2if3A(S^8_#I9UVRUW}6^BRk4IlxAY`66-5<PV+kJ)!YeW+wS5jn z&H6<|s#?0u`+MX@mzKWWY7Cx?QPy6+7!evn_vAxq9h4DqaU_&Hii)=c6RvK^dFq7y zQAu(GW+&MlR#O85^zk6wuH{%$Sq$#*u%HxDDDes%MSoRM;uEtu)aTDy&OZtde@e=A zJ`{)^o~ph}NX~6$(w^*v&>y6YW<RTn>7U25UHi3Mcj?IN>_PMOBD7c0t@Mul!`sq# zooAG$4hUWl-IJD*<5uGsHi4eP_@#i#>p6)DReL~0P`sHgEFlUe0P}zKE_mzP{P_dj zRsQmqy+xVN>8^(dvM_CL^eXa$?5rf^vKk!bttN`Yq`J#!w=G}EB?o|DJ%g%bAaAme zO&`?zH$BINUDe`MuAJPxdNIdK+--fNT9=Cntw(K7z?(<T`?_F6S2E%IBfw~Och|M7 zG_Wp1&z{a^sXryVx;jCD9H1=bgH{9Ck!^X_QsZy^s4>Xja9L~Z|Gu7}$jxB4HRGzs zYc-<A1#PvDO%JMZr0%Cf^WyAyH7VS(KYwF(^0>iWZl!%$T54=(Io0U+%X=gND(6A+ z6FvD0(UlY<g`Y2BtNZd-drKVxvGxy#OASI&8Jn4yR$vUC#cw|H@~O0-JS;(^H*P39 zllZp@Ia#C!warNnthS>fBa>078HyF=x-B<eO0may=*m{e)gH?-5ke1f8}rmTJv7N> zqP=d+KO&ueB%(KebE&ny&feCx&dGkMiPPoI*eO&6sMFYgb1l}^VKhJqC$5-Ct>cwd zek6cAEvE;!*DAc+nwyhuG_O`adPJ92YijN<87AKyUa(r7vMU!f097=|bA%jBQdB2X zZJt4gb9m8?lwD9z$k}Bg#x&d4i`&we{eZh4Mk^Cdy5~*ybKepL$aG-ZQ%tIwnQ8j= z$>OzARu?=x{40jSge!ynt0_t;m8wuQgj}Z7E%LOJKl!dSqMrBM#M!xdqtD1-YvUhb z{)Fs#pu_@V@rc(-A-#0ZSl1)JOvS=tETm{*AY0S8XP*;s&=h4jU6no_o}g{R87pMt z=5y!}XqvK%Q&{|9!}97pxpUK(8mZ{q=<MxE2Qy)TA*nZsMzg;|<od$75fn38HMZ?~ z@z4f?<En-_*^)HR`str2o3fJa?Q4?xbV1=`S?UquBey(<LN7eS+f%i|W=JLf(45$k z4=0x_@3U2~#fH?jIXKvOI*%AyUXqmh$vgWmeK>ba44jmst#|JapMIN7hng}qQWGiw z&|o!o{XXNqr*R-Tb$>b^b4Q3*7}JyKA<3|zdc-`in>VlYAUN#hq3iQh%I1*|$<<S> zs`kPI%HiBk7U8_?wzHH>QN$caQ{&1t2D|3?AykHv-u?M<a^tn8xXl}_Q%OXErt|kE z<Bo?dmM5h<)Jk+@4P0RSJ%yBj?GSKD*^AJ_B&-tkTzgr;pj0L8JecKjmii5M6lML- zbmosQCwL*CMQq~LoJK?cn^4F}XX0=!le?1Nz6$LoaXousMnT#KN?@@CHg+>pf?6Z& ztE;&DWHBX+S!s5$=3m!!t8b^pe{A;aS+6q;P4V%;5c8eu0gu%+CnNjl7}H^9LDGx& z9QFQ*?Koy`DPyqcnOraf-nW9{S1q}cw7O>tGW7NW_H>U=SXUa_*$~^4i7VUd0Spve z+`TjoG2!8rE~~PVmVgTAUF}lJq&r+5%S<SWFi=n^)XArDF*P@*WtxVb2nDajm-bZq zJwaZ!b~xE?P@{~<&4!tK6~-fVI^zI}@FIfu8jaAGO`VhDRT}zh#;PahlRO~7#_^ly zM1~wcjY5DI^{h4Di#d)`BH^BFjkyCZz+JQu#lHLR@QZMFHrA)VioL^jg&Ew`a~aI~ zDqN$y+Ib-LR3U$5ph=UXzl|A9lJqJx*mCq+RgCbX4DK1p))!K$%L5^ld@pZG;%Bk4 zwvCpUW|jfJy0p?p1e?ngr}*M^PQ8@K*m%pMbP8|=-2dx6fdvC*kI$a{1TRIZiFh+p zTo{7sirr1$u^3$jT5T&G61EcK=SudUctQi&eYhV>QkJ^5i=)4ky%6Sq#%~Tbm6gf# zL9DVlzr}<~$_JFDzts}Mvhp;6hW7ljyA2zUbfUKQjs!2ewoR{+fbGFy3xOO9+Gf-G zN3gbAmD74^(2G1RLKxImq^e$@5c|6M@V-d6-AOevf_S9NT>01+S!$$}TB7XYrk9Dk zTPsmRwXW<c8_Yn(UAyDuhR{oQEHfSN$<N4ipF0lAc5SXe8hfLOu0&b9Uf3(5^K;d2 zm~|x#S5%lEoSsI02@IWMaNDI?gPsd?;i41}U6<8tA4Xx9?d0~<?M_F^-&9%oxRwc6 zB6-c-J2464<Ai6CpA%Yy3CgpxrOs-qVSo6p_a^`va(vian%mU(jD3rWB3$r0XbwoE zZql)hk7qN@Gl(VGeORbR=^aL|A}~2+d1?8dUs!I$og8j6yL0#!Y_5iXAt@=T@M7mt zJ6j2qGhghBut3^;_5WGiCyBY})8R+loh|<f`iU{kvrx`Z{dHdzBY&#wghL~=t?bFT zIHAWpwx^>*g*~rW)m~nn^JYGA(G5w%xqGTH`PJK>CX#>sN`)(;<&Dnl2SV4KC5^t@ zaP_%s<o#sF?bIw-tINEF5N=j_^D2Gss*u}PgQIwcFJbS0D;VG0n=y`ImL_W2O>hHM zpS`dqwB-HVcq_6{_7?T!^1mAus`jYC5$gzupK<JMbvYxII?8Fs(o)2W(9hpIMpZ^o zD}=L%n~z^|p*!?btB(xzq2`P>x=G&9$VkjhGBSQo*6vbG+&oc1mYWMA(RqK#Mlc0; zyDB}VEYLqlF`%YDLE&L+oYSAJ%qYcpmD6yCJdGm#KRwE{e%CA04n}(lp7<}2sw!eN zCyKwmpZnzT-1vEV-q?37ZpBMti|+K3&pD3=Mm!@euU*N<`W0{TIFhpanpC+XW~`J= zUS(m3-&Rz(tXMVPbwIieYuMU`8OR2b2b15iM^2vp{DY65$IhN0RQhJ>9&~bVpLcSB z21-a5Y}%Lc#K+Zv&7mQ#WYa27m`j#Da_)6=-*#Gd`S<VfW>+Ue*_i}gPG{y}W8%$n z0}o93{;Hhm=4cWDeO#Dwnrvlf;eFhis93GdMzg#FdIfv|M};!YSDna&)`<Mv@q!mU z*|<BaD>|GAR{_WEP2GiK7p8q|L;GkTKJERKWTy?0FD4D_<AwRruFK|DR=Uy@{v7@G z&qzp7Um~TAjGhRoln+Yk!<yDlu*dg*y#U*;0RNY9Jje$U3>+LAbIaV&)KkIez`PWd zuN7f6);l|3Cq@FuRhFf-gVng$1)%lPNm=o2cNJ_3)^ddEqA8VKmN7c*Q}6d+2?M#i z)z8j$7#hBpXjRPx#ZIm(%ZJ<aRYenI?U&u>JCBsGs!gq}&+-*c%bPjJD%_IZuUTrA zb!prGQD_2fhenxMcO^P$!n#`%YwbkAZI*#-kNvV`3{zRxgNQ=?UcLhYLH)Ii*SJJd z#U~9cowrMhH!0QV?dhi8yFZYS@LQ0H|6J>QE>(aL|B59$9FnP^srOi!j#La+!U0BK zTAI<>IWP>D>Yyhz7@_8ebGnH8+)!8DNnqTCm6rZ4`c~D}l5bjxK{UeHXl(~pHnw>U z<nEZujT`dN%BywiE9lW-SNXC-QPyI&7`)D|b#0>9AsvuKocv8J6$XuR%{v8@0Z&fc zR>9&}K*Mdj1d|jRA=|dr*4`=9{n(N3zO8g2OFfz6Y>X2MJmZaX!|l1{_hRv`<2`tj z+@WvZy77R8MR-Z8&V38+4R(gQ;?hzql%YMvx6;AJ!2<IYPcGZ=?4LE?ABi9Y&Z-kX zeUcVr>`bh(o>urcg5ObJ4<sbF#ypP{38HRG^&)Wx2|T~H9M<N7mHyn^Q8!stSgayP z(WVg}Y<CeK=*jXvU(=ty0hjjg#R4qe`H)0mbC<n!ope43IlQ1@vR@gbNhgHT4hWG8 zeJHi}JgS(JN$|~R@Hl8>qE4(POC4e*=GMiW4O>gK74C3lBw0M4cyZd%cDSB@grSlr z+k5cfgF7XEnOQGQjqa|#-l>RK$DzmSuE@FnZ0wwi@WUGh`7O2mt#9l=G|?Ap(&t*i z9K9-puQgI)It&f*+qe05rb3C0p!a9Xw4~Cm%-*@ShTsX1dHNZDR{UhQ@hK>eVDJsa z0!z*4Z-2$NznwmS4?qR+*eX&+ud4!NCG)r7?5*>fw7HM&)oR~YOBF}|Qk6)lI@BO? z=)NyB@py+w(_k786-b1XRz*&(cJ_ztRhcbKiN;?o-6~Z@B;0otrSgtDO)yF()~~s8 z2iQ*R{niqQ9+_LeHWgW3CR-_<JU<g!k=gHlC%?ZB6{LIXLOZ#XH+{uWzxmgaY8D%T z_$Mwd2ZOZtcbNMHr{Ipc$^i#@W#kJ_-hc%<tt!vfKN0EkJP~)cHI^J+aQ|D9QPS7^ z+a6|cFB*S6h{L#nlEEy5`GCHqc^Eo8uyahF2uR3|fZ%j#cO~57b%SEa0}?hF?t}`3 zv~Kspi1@Pgz8uT@UX*j!TNiSL=M;;P{mB`+hjl+C=+o_-vIolbFQFsn7{5#$^?dzU zdlSno=Df6Y5Ll<Mi^bmI%kY}+P{%gr8XWOST$H60aeV4Yg$+$Dk;})R<3gomQ)M(A zn>M<8dS3U5MJP1u$_oAi*wUku{ND*e#mmsG$3K&ejyNU^<1R8dDm9a3?8K71e8jj@ zW{d~k)pOFu51I3O=$m<x>+(dA+agk1XvI<5WU@30rswl4gVds~$Iz0Qeuis;_pzBi z_Oe>Q!hi$FepvgL=qfIHL7yWKl^g8uf3hBOh4upx>jIHEXszU1?lB1p(#>U|quzWp zT5+v#1_x6_xqj~g6;>fn!_p#Wp3|kiS}Hn!pk`2)AbMm3l#lA_nwqh3vGazlXCzvB zt@oH0s_)-VHl)2N0#lYW{>*50|7K8l*|YIjz<{b3Lz7PxMMgrh)p-2d_6hrPf9jtU zf{+2FQ93$?ET=jGf{;)aFPAFlv;`&+*SZujR3=o=RWmdHxGLbtMa!+omlz&yE42Cf zmjX7(ZLso1=M70Ugn&X0RR6BG{y>d<Fks%vf~K29DY(LVR<v;Z##$b_BiL66D;RhM zEQsQVdo+>V*UkIPOv?U2KuDmt`~Vr>^O);NbLt$6N@L!NBXmuUBUzPsbid7I5w0BY zn9YrXWX*Dt9)LC+!9jr>iv_>xU%#GBPByqI%cQ6{EYj@r^yk%!<>A+tq~zqT69)c6 za2<uCDl3&4wq?0AbqbhXpr%JiOW59Xc^R;>i~*^sMtgsW2Cu517Iqo34*F~9_O@44 z>hTMn{;VIg95d5L-g9HE(y%s`laM&*Un&iIz6QlTUdZL=5{=Ap&&FZALbR?yx^p`` z-24>BQ2^RgM|ss)KY2jz@t_a~%z?vX{;Zvewa&-COb^zu3Zf~g2iWdn!nyp6>V)yM zT;bg{;o`HYZWXp;ILT(ql+o|?QB7#ZeAWmsQegA{5n#&TbpaPfhaOBxAY9U_pMlwW z7Oz5SaWNevrc4n5uK?$)=ff)pn3=ARYj0eAH9w8n-7J>WEL|6J7MR+ry&Xg*SZo^c zjx5l;KkHuR0Q5p&HIZc!sTgzJf9vm+KWYy{{Ofx&NUovEs9KM2q}t))=gp%G^nuHP z*{?4Oy%E`iPv;8(nyc;Rz{uIY?U$Y?Kyn9qO7DejMRc7N``spzrTRc2WN196M?}__ zA7f;TFXF!Bon&;vUNPNou5VTxnUqv(F;DK#2a}(;he+reP2W=mU4T^dq`lvB{EOm5 z6iR(=(B5t?sh*(Q2JstBUu{TT+aC>+a>~o=Qq828WtY`py@t-VoG&b(-o9Z={p;85 zN%2Be|6{raXwRsw+ZW09UddN%EOosB9rCs?@7WO)wt5v5|K<})o&<1|&eiQqFLi3K zH@L1@w!IboGV|5OS8QO2oSN&FB--TN3%}QlD!t)qo7q-g@_s_hai7XT=(pm&$lf6v z?zt<q?6N&1*}O3qH23v$?YGw(I^*;Ce(zJU;uz!rbOX;X1X@))hi*)WUscsFcQZ!b z7u>-L_wQ9JzvIx=F7hlQ*3$kwUu7~qm_xUBKlCTNmo>D!zq@T*0F}hINOxKp3Q|%% zNet5suR0x<@)FbjrwXn0Vmb%$!(C;G7S^4j{(ZqokEr&48T9sr(0X0&qc;2vrYn|H z$LwK#C#onhuA4)alNEdE!?pa#p~v_61Ydh#mh-?xF6`&;%1w&nog7<eJU_WATxvy2 zYFcLD4z-wn#9P`Agnc^EuwAS;@;rziD&IHbzn;>oQErI-{`<X6N`Tpy@UF$d$wlaQ zif#XR?n79*K9mq2Zw!5a{JfCM%4VdWKyyhaz+(0>REwf@{&n;(c|pNKWX&(VE41|4 z!$V^{2`*j3TX0OtQZ18(H;z~)vedluls#c&m6v;IJGKa0VK1@R{B@{kZ))nTM?ghS zbj;(txASqjFS-4>>B6~(RI@GU@+-*6*-3w5T@om%Ru22vo3CM<SOoiS3fXUrWM)*L ziDj@EgP9Vt)cEdj`p@ckokwpv1VS3K;nrU5V9VIrax@F<?``BFuRreMy3F6hj})?u zKs^0?xU<Tav8dRFX(oHn{IMc-cv~W^MM_;;G{OD;0UL4e6INK1g@jZzS~fr&WVHFC zMkn!#i<YaaICy_AKSucfW~5s%#k~S6F$wQ4wI1l+xcOVPI2!*fFL%aIpIg*&?gl-g zE%K!qlPSu5Jic^o(|qE&ZLua&@ifa~vP6|AvXmzI_@IZuX8xXT^n{j!tx?O3&|3nW zoXx|{-|W&Iq25%2vM}L4yp-~b=}#n3gxoltz4v~2xZ=uLOcE+d^{!ai{nK_r?}rgy z9(=rEV0Va9gA^>a$oURvHjTY`nGe0hXD4_f1#}rLZ7m(lF*pLM7p$w`htxG<;MvDu zYsdczo}D5`VQlY)MgxQ0hzaLZm~SAubRhcp7SFlDeM;6Q{$kGYJ2FY`UapUMI4OF` z{@=iU@XxpN@8UFUI9^t(aB-1rUT(9xa)*LjQIQc(Q&CajZ}f||eD*dDi!dWUlVFfA z)y0NpK>l}6G}M1)n)v(B4cWGraz3@KMHXMX;%#GBP*hr2{2!p`AtDWdzE^z_L)@H6 zhV}VRko^!5MWP2mKH)kSoc=wiGg_Qu{`(~EBq8t%uvqLZ#U2tIr^wVu`-4CK`%Y}Y zbx6dC`W0C`YHjxnheXP=RFcW`KUuhV+RUY#?N4oEV~?)<pnAZ_&%q|5`1g7Jh*7fk zjzT3RCB~w0$_<+3Vu&LDK2nN8-31j_Aymze1R2kIQ!@Mdzy7*AVFA|)mOQVFQT1>a zVn6<crQnFC+y`1;4&{{}+HaG%&wu@gvy!2ARBTi(PC3h_-Cy|7o$Ge!cXNv@!-5r8 zy{j&Y{RP2z|H&wzR#P9oCU6nMJUndp9|IxfmHq`4B>Q0H4{ds3KPB?>|51;a+2S94 zB=sKaB@FZNf0kD9<ob&_-@n6a(tc0F`}c8QD(QK@FeaSJs}K3_*XWqpf3&0*;aaOs zOAwy_$itf?xAo?GRWFrSZKnM9%>1ts6d*$)=OPUnFJA1t46Q&@7RWw<gpr;~WePkA zy#HJ=I8G?xyFUT~0uuQ*^2e*3e02zd0s~pn&=Bw9WVwMHuMDZR`mXB0@qa%q8gYD; zoPqdOQjSt_D~iKwr(bn-Adl^*EC5hV8o0Sv_%rgAGK`Zv_#QnvE}Qf$0z7qYEw$&# zA4clEy)Cc3YNwJYtM8|-Q?cBW9%tI^<h)S<yIY-Npd1uE+3sCfa8lzJ8hbLPwbcL5 z`D@$XAhX)N*WDQ@83A<FepL8{o#nxFDO8@QgFEDL6c!ivm7dc%s&Bi_w?v7!Iav)C z)AO(@F%RZzmO1SuPA;4dB)^eD&wLhgz}hy=kA2pvJ_MtRBBPwp(CDg)PNlsha}jqD zArctO&)_8~B7ZkFE`DoFBh5<^-YZ+mSZbv4`V2#z^RKb-PdrEE)~EHU__x9%2wWXp zTwQARXQaT(40t&1p`rSEkY83}mxi+!&GV~kkn1A=q-JG(1PhZXcCI-vnibho?yrEK z=c{wH1>qls-%-iST)WeFG$s^U)sD)EUdPK4z`rO-bKApcS2-xQLQb9(inV$k{N|%7 zgasNU7N*HDmnp^4{yrfbC3-N2dhGP0%v^WoAa-GQ$N;n`^jh70efCXVUp<a?j^-DW zY7Z9Ti;Ih`AfV%Ch!}I!U1lybKtNUUPESv}^1dGK@ls?4vaqE$ul1C7bnaDGzZ1Si ze$%b(Ub#JrE?u1k!^KZ`M8`TNkli!Cu|1$mvP_DKdINT||2+#%cotVKUvAo3;xsU7 zi}@Ov2w`AtR1zZp*9%aT6QiP_*c;UKqJCukDwC4XgMwYH{1uyo`17Vb;FcZl@K1E{ zId3F5`E=ldoe>2{@K@$$eK}vXSLWteuQQcbmOr>c)&8BF-z>-E9kUA`H>aZRe+F44 z8+de3jkZxZ9PfXdOEtu6ER?2A2oHCi+tg=DMW-{(M}&oavs~pX0sRqkCs?vdOAA$` zBcJ%-X6^uLKiXJAq7nE!rmDqG4`Ob%PUic{Y191gnt$^;XUoeRBjmh1gZ^f);W6K@ z3hr2WY_}b>fZg`i4(4X%IueDmYm{0TnV1fM9o&=avizI0{~h0tER}YTg|=AW+Std7 zoV0P<eEWvPVE$Xda9Uc1tn{29Z4XjnmKGKU%5-bthI7F5sjm+xsUZ+6rzc0d>*Hm} zabOFmp=SX8CwjD2Waa|6Xdtfj(s+%Hv><X_R&&b$sHWVuQsi}_oG9#sIi3;whWZHZ z%l(v)zQW4GLbTMSVCtx+NMs!wQ7?CouIDm+NnOl0gV7Sp?Eig$k`3f~rj?pIOT)9S z`#W2UosGYKvBrq$QY$PkU@+R*(Z0UErIrBnGf^|KS1QKfLN?l~blHSl&9Z2(89M;o zFebVybdMQsX6M(0*F$bXGS$!>_vN0<2*%ZcY;z;CeFlH^;bGN$Es*u4A>Wv3tQ5X! zwKu=WL!59OZP`_-4Y@3TpUkT_WDeJJVDJr|dbg8G2M7WbwqFX>AiG7y7V>()tT(NF zAOF?|BC5mL>tZjx(Mq37%m*@j!ADnWHjZ$DD^yC0$L<^HZFi<}-P#)0C=3!4h+>s* zwW|5k09iw#hx-vwNi?^D_Y{A&5qP@o3BszDcO^gn&u^_vcJ%L407=z7`_M!S4h}dg z_nWI;x_GhUaw6C=x=KNOhaAZ<1?H>SU>>*)Ee#A(l!9x|PBqycosHRLtK<(PXnSrX zitO%wV2ls>Sgupv)6$X!ZZd_JvWOb{<?!n2lcq4*wswLVq8;}43u#7XAl`&8Z|07S zOXnMarTS~8$qzNyC0Ld5Rh=9hl$m9_hQGjzM0{azi;9ar99hlxI9e|oL310my=pN8 z=PhX85`-KSdb3|&1KaQM&wGFOLmZ5NIF;V|co9D!gWEFO-}~Q!Yot)G&K-T9Rkt!n za+YB^b{X}Iy%xKY%~7*ie;iCT&(?<qI);t4HBb4>&g!(B*hz8S3&_Q?TRa8#>3Ah` z85SI9D>ZE$Eq`G@AkW50Au4#+fh)2%W=G?``8GE<SAhX296BEByy!6qCwXKw@jXQJ z^yRukf4Wl=7(SbWOU&#H&W{{k@^*THc*3M5!>T%-f_Lx!7#wuk-wbQXeJ&xPTVeZK zTy6C7l?2I`FRk~i%ML+6^Y^{NabhtbEWlbFpMkM$qSotdd$|{Hn`UZx-lcU1ytDjo zu!c;Ka%9&V-A)p<;l5wzheRR)646aW;eHatk4tptq1QwZM*HUnB3@oj-g2a4-gKS< zh}gyp^nD^ydga8<46T58+p@E{dDIXDK)m4;MxU$yRT<iyYL{4LSPhJ=P;uepx2R5l zYZoB_p1VrN^_AIK#cUNeIgWgCUbB^XQIHgG)EotC5D{FD-&)OwJ6DUT43wyr<CQx* zgOgneX$VK)YmlWhemXek%&s9vg5YzP%A>0~v{JT1S0p7(7=c^!_$bNC=#61t=4^^4 z#|E}rf<{wKqv^=V=#9g2&x?fU8UVO}iLR}aqiI)KMn)LYENi{I{#(bUkjBZl_vcB^ zx9-n;xtfd&45HX3+VCUy-RAVcr}-pE+U5?BPTunc+{gEmT}_3T2x%cVOit<B*jRNa z98&L^4nj%>I<o)Ag-glzQyei9Y%c0aqSYLJTB43?<rubq9O&tOm09+xuxb^9HyJ#F zrV~!8u*H*1)lMzUeEIvSZR&FOP^+yp3Aj-6m~?qxxHJG8_1TZN*{XSzVxEuK*kmlX zV3f;H=DJbsbe&2lwaPVuK?ba>kQ`~Y3bCw$7DLu(X`sru?JX-hM@~kupn7|HcbG#c zMOi!h&HMflqELd<u2z<dv+MsnvJW_E2@Co>^+%>{ef+M4`6ZLlIWs(b2t%}ihBTX$ zh5)1Fy_2Iob5($jd+t^k`w>y>9%ko5P8uf=PIHrXTGzPn)FRTQO#!DgeTV|A3f#T8 zst0a6X6J^MrLTTEE<4KWt)#ln)0<y^B5u4+Hkfl6t2StFzvd+aWZ}<(c4_~uF4n5t zJvgoPJqn4v++5o$Ah<m~K5;O2r`R9(lW8}PC1H}Q@M;091bTPkaJOnhK|$eYZ-avD z1``MD1}TxslQkZM15Rsw&EqF&7^UgKpR$x<U!$|(7ASnfQmstRi_AIV0(%J;vjWq* zxSHR;ei8F3x;=IO{=dgBgU8P{?M(xDmR6Nf`zNwah(;vs?HL<8J^*w0;c_~f(v?k# ze-)OxSG_C}c#VG4o<O*QWv0smuy9`|=r<vpPN!UN`e1AEL~~zEYwf5b@x+`{s$a06 z6gk;I`CrZZg_&FVQ_nB|Tl0eK?8vbpoAApdllv<r*=BWs94?z^=|Qj=8od#J@q#I8 zS!1ch#-K4#)T2_OlRAP??qIf4jDp8x6Nq&X1H%F>hyQQae0eEnqS`H_!s!z+z@Y_9 zrhXVFx)^n0#z*jT?S1|HyiWQD%>;oM9PcQ3VDjUJ({(5G7Z#O;QLEZqrC{({I+zaz z-{E`o@Zl>*w}rvUmv6wde|o&i(OeMCs?O!OHbgb*^nX{*ty>Ps+t@&VIThxS`YQyY z$D7aAE%5;Ts8MR6Ui&s5P}&g03j5O+lS<L?-Xatx{SKeSkU?*{T<jx31_p6WruDNp zemSNxKtt#fRk*JW*+BWt#HwCt2-JdZ`R+oKL%8Q*)vPqQ^9E1!bSWi@MHNv2mt~0P z?$Vb!AGkz-Mr6<^cC^+zGs$xI?p;VC5G{;~Qs!*easPvk&edRkOmA&x^WW)fKcCFO zn?5-?x%L<iPw=|u=9UKb*7_BN-}Zs|{Rp@aOYA^zl#-I^&kZBagvCVxS$RPP@-uum zu8cz71}m@r@&0W#!t|HvatVi!r9?!-UOozcitewN=S%80?owSB0ax^mIf16`SKf&K zQIa3xHc;!04>?PN2V8S^W72E-_+WE$GcuBxo4e>7<pdE<JzycY#dsBS;sn+DAn%{h zEI`5}dG5w0$5LdwY%L&OIIs7s(%!#cdvq$SsW}QC)$fMi*JwEFyX*c$vmnRHZQ3Wz zMNifg@xK$dXEgkJ{~Uc^Kv0-u@&&;I?*9r1Z_aY=i#g$LJ5j3ec_v3MwkPEiabJGL z_dgz;fpysxwb<C$t<yNv=kT%Q7lDZ)Zp=6>=kjd$(z}K!g_+Kk|K(QRxdewi$SWlw z_Py}%s*qFP^S6*W@O|tN{t3Zp%GdMX@czTu{ZKineeO&;N>~>HrqdYGDh^!WOz3-@ z5ydR0kTrHOT<g!?`C;ux!jluyJ&M7Hf>-3i^<yQ&-xzr=?kZ)J<FxkCp;m>b*zb#( zq0L%bIy#!0|DK$uh*0tA2dW2almtLj3B20+4w()oR$?5du&m>h=s83o%ik3q!)}YF z4H4#S$ZERx9v=H;|FJx<LX8cLIBTH?0f@pFLp8yQiUE~q2B(hD`m0rsdHMMjamHR` zO@#;nAX<brV*C6i`=Kqz?mmZQ+~|m1FRQ=E+A3`K550G?ng)apl^kDs4!+mFONsIa zt;_Rs6J*?HDr(+uUMH({;S-c=Hg#4Y+PwjqqT=`;|5jOAWgx+3FDu~lY(q=W(+k$B z846;HP~!A}Qy2$FIa=#qvNN@Mcu83!-n3KfDh{3<5dHG^c30AG@Z!#fZ%NBYTja7q zgFxWmUi^e$S@MH)>HmKJ;bCA3{J{I5l71?C0&yJQW;r#_CPaT|LPA8N2;TW-{aAi( z<|TU=v4L{kh3NOHrF?Mm$LL{i<=tm0zN|Vd*l;vRcK%e5+y*tzv!-yQhSbz;g`Z9l zy+I-w#Prc-O-3`0=RZ&$+wEANwT~kuFftkw#})c-`Ol`#f#wPEOmZyAd~mtq-vV|_ zc34obL9i2nzN`jh#Ngf1tKPx^#}?Tgl#U=ZIRB6IULx~2b=Z{=-t=wl((9JF@XG$d z6Q(#AW>)@SbNb@^5bu5V*+z#wmL;lTq~&R%Vh<NFPJ_&h5lbuL*j(?u?G_fyzwp}b zHMmqLRiJX<Vv3JtNK0L`OL6`2AoljP{Swan*ZewPj?O=P9O7qOtlWyT<SB~FZnUHg z?7@!8W+peS(;sqkJ2)0*n=MB*wbClU&;-XDvRKKr8FNNCO`7`pehqiCi_o&L(MBYy zd2>GGl()#uwa8(MV)hsPd+oovM*H2Q(xYgi30+tc^+v<h$Q3S+sajewamoCwwi8^! zrvH$S-pORsFM2H#O4Zq8)-4ExJYeZJezRz3N8vWbSNM>>sQ6)7Vd2Bvmf<K|k-ymf zFL#tH@uH)_lV_;5GK5}R8w0i17ywr;9*mJ*%XnavR{#9ez{u=1#Kr2%&ofjq<NgN- zC-%XSO43rY)Bf8X!SQ}=ZTI;6J;1@a!~4H8#{Z9hgLG`~9w5mRT(8whm$iiKo_R5B zJOAXBMEuBlXc}lh6Y>0p{-F)p3moR(^}d0sIT~nHQy|>~w4u6EHKIZ0aBssL4jp5r zr53}LX8kLGTh#sT%mjp_o}NNLHhOxtr;9u+v6&TbSuZ)8s!I@dL`*j`CjQ+b#;Rc^ z`bFWg-}|+RlByV7$RM9>2=<tdLP0iql<?bM%1MBEYKxs*Q89Y+CZS8M#{D<&Df!&@ zU;SNan91RNR$`Zs!222v%r^rAgMM3E+m<!mMF`V48ypscyv7IO8()E;Tws%;ft;+- z-tzBzaO?ki0Y>fB-ygAG@uP&8jUMzAG$xiq#BNaCI_dK1jjS5SR3Am$j1YD*+nAZj zQPKxkBG4-=N7K;h>3+6VPzIjQHE!IXue~c)n<hiS-^UIkPC<`D3jhJAII4tTv>`Gt z)pjuP1l*Oo73ppzW_bwH%L!Hg{%f7=aWqh~vl?ag8x*uBN7n^~1RYm<<YAR~eC6Cn z-?Fnr8}jIAje7(II|Rg>9G~n>LWh8j!{^<*G||ne4?E^?vLT@@wOn=Ntp;-Y5w^AS z0})xWc4S1v*1W2&*U1jQ!YOnCXg=?H`}_ObsuI+=^9nJiDYJNyq7hY~kOvt+Ly(6^ z)F>SXht<v`1-q|SbRq1HPPuU-FT!;o2k$=k9nMMpF!WV;DJLgpFYx#(2o5Vjv=rG; z_e=t^N{FbgKCtwrw_=_~`K1e=U-*x&%&@=5v4-}rx`W9i#&4t6ZS9*xUmOt2-H-n; z{?56NFR!Geghb{kaG@>pwOM$1OES>V3{fWd`GK%oCZAX)c(W&vqu6{<rDb}0a1S$@ zuhbPR`R~8CZl&Oyef$&<zKxKr=%)Sf=;YVW1mTiib|Jfk@2RR7Y9yG6Sprx3^5~-= z?jl$7(9PAE$F#4Yc%whhfd!(xA%^9=qI!7ooIQWm0Kgn-DykxjVOyrNKa1ZXybwx4 z*|N94_`TZW#jGaW9V+diX!t-#AsfqOQ@{TQ*XmdmyPM>GI6P9anz~tsx8bxrW;$K} z`@H8d5boVP{?nlEqdugXE==3>ekp*2vTR?z+|fJ70yLicr{7W<Q>RR<bQh=;f$4e7 zWxg9gk`2KW^XQTihpOFmYLBI_ZmxU4Z{FRVmY|kGFfV=DI$X`)-58Xs)4q3~OC!+( zMq;^|5?1nthLMl>&3j&2J(?A%u4H=ClcWAujfnG?cD_0JkdyNOKwo9Z;W&W5K;Ow_ zD>{tU&-@&sDtfpy3uLVRT>Pt7U3dEVFJ27pJU*1F&W+0QfE))yNrjg(GO?%ox4h1( zcPC%S%L7){4PxfJ@^#P@8N8+=&b<rm3PTm<i|_IGNKOUX<M^T^rVo2OLo+fmrhiI6 zl3V{kQ{8a2dD)|y<C(1^3`O=BLRz}5{D>igW;3^Jg08wU9>#jDMdrMh4EPm!1IM(a z_`A=y2n;dQN1I6iAlch8uK_+Q)5dU>1pg1$lc2J`E@<@#LFleIX3+`z%YSE;A5mCj z*xVDxYYu!>g35SiOSgXhNp~;EgHu%<)Ddw5rXW~K7dOLFz#X#Kk&whWTcKX$+0$Xv z|8+d*n?|^!pkNKQo}hEtp_>_z>B!2<>(Fx2+qa!16F=bLx|gRjZVohM_#owEWnKQD zQPqz`Tz6O0+eSgGAQK~?E|WjuJi7zYY-&tNLqp}>2I<Mk1VX3I<y9r7y*(vM{-Xx3 zXM<_PsHkqSHRhI<IVA{9fHsQ!^Nls^e0F;0<=>5raI6@}RBQz}s#zYtFBh0tAk2Av zd3hpDIS^R?DUEUs4Zi?N)QqcHw(C^j)HSLHj<)9Bw_!KrIc@#~@9tu#`!x=E%c?5m zbnz!O=;g!K9;-P!i;DxFzkjtaZ+>07S1&|R1OzZ(CgbDgUmeNx`R)rMZ}Sfyv{Ct| zBPG@p=H?qCyw&FYO2+!C2|`pb|7S&Tr}7BQ*&+~i)`JjD-nW}qVYgS)*H>J=@0=;< zzC9FMw21CXF=yPF-5B?RORhrEuhf+rqg!LU-WFSA^T!|J>TPxUGnV_C%HAS<z$^e@ z2-BegPtcJ?#b2KS4K-GKac<7pwOlnHGn~UjRRY%lGZic($0#_F_TjKR<;Fqm9MR0k zG3_7nsg;=d0*@<FB8c+O{%kMabux$$Ifdp01X&kaO^UuT&xe{XJ(%h?o5o%FxbN<# z2gVRI#^u_kR>1{~$!ZJ7m6N?}Ht0!=ttRS>mRX`~>zsca5C}P~3{|`BspM*wSoT(C zkS$478FY%A)i*~lq94BgRj^Teq!Q%W5L~TFJqy^#jt<YcZ_8Cqn9XgjN2bmi8Y7WR zigHOrk=WJz>cjnQi72f9$4^=XCcch6d0<yazv%OUXxXaZ+Z=hTN?vnkr>ZW3vS{PS zz1juXM5`t8(a)K|zSCWEZ9rO5w7@ILNQS}`F!ul&VP|2{e45>^>1ek4vpzpb)DGG6 z-YtRhti9mo&6}!u>NK~st$m~buL_8zz`A=k#R#dp1{>2IO(!+z(&^54y<#0@i<>gw zDv~X)ssh1ROHX0>H7VP&H!La;DG_u>s6c7CzmUZ1TwR<Sc7j^Oj0=@njUsX@sBhfE zd(H+e*yd+f0mFmBS)KVkwg3LP1B@s64>1mFT`Dpgux<DUd_qo7O7s8d5hk)#n!l8| zS1s_xi-sVO;;i=pWYbT`V-c`P3}YJDMlW6JG*gXKoY`;gy?0L)muSfVE)n?5zx04< z;fGiAWT!6M^JQGM+Oy2t3&%p5gdR!&lHxY@6fu&ndsz7*F#4E6FRHLQy2`IHM9eck zO*IQz=8)W&4ZSTfrj3abRSG>wSMP{DX)X4nmgi>mH{FFTq+<|iWi<%xxv-ypP8;1a zPKxO9Z%a!{yyjPp7P8jYPx?{1OO9IM1#8RciN`>1gpS8xj^^G}F<#`;!ooso=S_Y5 zsnp|<Yz{FXT%z3-6ctqSM(z$HXeSTZG!G$&p6mvh2Ucn7kWf=vX2<I7O{!Z}uIu80 ztk7b^?&MmT8+h>YSd8wDr_1712nd&14Qh;XZMLcV%UW1iV3vn-L6uc_B6aWHXz9jq zxl4RXijPw+d1ZXmWf`tCM@tPp)4r~fI4Kj8eek%_rKnf9j>EXUM7|a5Y;@ITBgOI? ziQ~CBtiW!$1cQI;&5x;UA&&%JxY0@N4}{0D=~l@0ung<1MtdUrv$R7#3R!O!eBX%M zp^zY=(CX7>pf}K{Jhl@2Cq?|4{NgK)dxELqIxf!TPAgq$rB1n@KDB}O$8l|}%zRH& zuquv^J;qBLnpAu2ATAWN-&X_6fsm6~zRBUkmaRz1**TecuzgeT*?Y@bT4l1P6%B!O zz?PEEKf*i5Q=8_1kN&NY{q|4=0jiC)4R2>du`dAPKq9tV1_s2RHk0>P({+aT*>=|W z7<cMMzEZAXIIsDlK2-gC5iXLBU2%@6u`$XLYc`(kwc&S(3_3bG0E+B2pR81t;uRS# za*`V!SzYNbl9&$j-U}wny?yc0C5Kf29G@N<U}k<3G{T+i<jRU41x*4`)0UGHylcS$ zhx{K{)`Kl4Yo4vw^p#ptxNuYhn3ITLWMd+m_`ZbI;Wi_ft=Ryu?z!B|nDnZ}a_`iK zDU&H&@fvlXTs;-k3UR#O&xO3eM-MM}aD{22HfYdlgc)C>lUkcD+t$W0{A4dzSI(Bw zU*2(jI4RCHg(c#-6xei7GC`E08IlIN{!f9}t9f{|v(YQn6AJ8#rF9=lO>mpJ+HcHE zFSF|hg~+YzKf`U=O-V5brWDC54g0>_(r>P?G+q>+s;Us$?zleoRidl|dmEJPXpdD$ z!)e*2=*#CX(6!=_lXKBM*auyiy;JsWO85D(11a3=C_~Ge_aybVoAl9${0{B%2Ny!u zI-B%;+$s7{&tP#@D=>&lG^gOVY;9)RH*G?`3@|fN2~`6PnYvj<dMKr#<#5l~Q*9-s zmgu;+(l?q<LQ3DBxRq^NvE$Wsbd<VmF@Qn`#r9dm?Fl(?Nw&+u!TM+kxuCl>IOh2H z1`1yn83k#Uur_^hFt&vG!{aw!ASU1#C3|-3CK%?jeG3Y#Oa8ragVS}p+E+EK{7GU& zcsN|-a7vIG)p3%aZNOc?Kr)uY-sZ>PfO9M^1oT9tq&SAXzo=bhAKmCjbG*@2_Y6=x z0OPYhd+THN>tAAG9<%N0)@W80zx}8KA6DIO3U0m6a$|;uC^UP=;$C(BlH6-FPkS7G zZ3c>HCFZAw&!5~GtkyIhKUJ~4?CQ92^QP^KvNte^QZg+5^}tf2q$?No3l35nfv9)1 z$;qqc_iG{B@t_Z;En!;HW$3xPqmCH=z(B~JQh1i(HJK-&Iv!nEtk}2<q?CbR!Sg1_ z&$!Laf5zRmN{);-$Adx?XUd2OLS9|{FjX?p?oD?yJkt0mSOj82f+)s$R3j_@(6^pO z@9qUtGsmJ7$OrRD-M{8)mWHxQmgy+);V<8(zZDeRJ-Ubu)sFgHT<Ro)-)awJrEHl* z0TrTmnVFLEtA|Hq<uWjSbO6IRDH&PH`8v#Xu2%F~?#t{ot2;@;Vd2}IWYKT_5TFjU zpeMFTJ*9)WnO<9KeHuINE6>dj6|U%3_GKw&*Ap5Fi@5D5Eh?fySF0cuYWj$Vp7b9g zBK1=D-_Ti9NxObiHGCI8?n_qJZKM)Hd<zEJP*$rmB|$IrPW$Q0f6!%3{lAAHNVa`V zDNO!KlG1BdheK=a?l{Rtdf!alP6?)$lUEshP;%t^o%XUCa59RE?hem2VbT7aP&|9N zWNhpzvY4?V_)q~`!XQJQDCA&tkX5p?a>$dRTc*;!!tQGn$1$zhdf_|ujm+ViVwv)T zqF>DXasz2s2?@nW?$A-h^5}okai!PP{BDm{_?Y7J^UGV0e%n-|#<6k2F_EtJab==} z?*><J)o#Sy(sTk6T>Yeje0YQ+II?=_F)b~Tm(axajjf9CKE8@Qr*hTWuk<mMbY<-X z;oDPEp05AdK5?47twYbq->AhMiXdb{jOwVG#7x`BjdZ*!F&lSfjfqH^1-g2&%O1_# z!BpKdGjHXU>od+Nmt-4>)**F6OUX@BnBsTn>B*txSh3_)tLhUZ#J;d;fXC+^AG;1# z0P;@8PhT+ub9Q)oX~}C=aEIDAz=)mEM>EjBCagWvoSvSZbeM}zh5v`G1E;mu<DIm( zuvCnQDxSFR6@M!gbr4!oux_Gn-(KuVQ}2D(Z8>tB{oaa=M$4nZv~~A>-0$Un%2VTq z26|J1VA>1THyPTgr?7F59E|SQYTIP&SIQz}M%XKbSvg5vQLJ4Tqia+bu7(jj4w3@; z9{ZiFTIFg9LK0kI_k3`(qY}@g5z6^G<8U~cw55d;Axp+9W6Br3RdhT#Q=ZlsYHG9E z0$_N0d*d((2niKim^!DeoO&E(T^RT+McP_wkeJ9n!f(>`rI==}0&*5MkL@6XsOR{A z0IWIGp8MZ|C<ZGcnNnX3kCJ@a2eoHsYb(p6H*RLd)}g8Y*9#bt>7~mo8!aj>zQbqu z<@5t~Ykst(=o<*jYxCr?RZam}RVhTeC7uP4yq)#p>A25gu&b~1WS;bAe+9ASyJvZz zOIjZ(5)+t%+`pfF*Wew?O^CE?%h1P-ClMuoz4^tHf)6PIUjNTrp6&4_tI$uUcGXju z-c2()L;k9pV5g;hCTcqEt(*|tQrDrADG(f2#m|6ZdVA>B-mnD`8Y3e-mOUAcxpB5H zb8~XJxVZou@%N4m|9i(8v_|K0)NB_*_)>aEqu8IIe5*^>uZO^iY-%cWXlTf+AIX`M zS-kGBK2gmGgj<MTDYM;*rVz1@<E`SHm6Jtwc+4^~$N7g6QH`lOPdM3N`y@+VrMT`C zuB)LswEOD2y$wKX@It2E^3|E=$=t!oS+x@u;xE!K(R;gLm&v?NILnkMy6Nxvg8!4w z$>t9}vL8u4&Tt?<(!S-MJsu2!$Q~=sq_yh2sYRvL4pPd~7+J&<-K=+_&oRhDpD8-F zEFi5sg8qX0j>b@cviZ~h$KG3pMcKV?qxcwD7$B&mw6rK8C4zKH3=Knfr!<O)w8RYE zJ;cyMqcjXicPZUHbnG=ezyG`6y^r_9{;=cS``GuF=kOU|*1guc*1F=n&a<h|U#oty z7bVm&qw%gX{fYr3FQ_yQGpV@cUd?SbA0Hi$@|uIQOgL9pRjC}FpeW!#5NapS-OW{` zt+}<i6?9F1unFhgi3OyWigxoGQQo0?xe{@7E3hY<H7*%>YYk(jhN<i@usw6`Px?K= zu||OgBcS3Ta56dvvLS)Sx2MG^>X6z%)s5GD@OJrGJ9Sv@9)TIanQ&P5=X?2z&9#QG zX}W8J{7m0BQ^q^-I-VHL^2rJ(i}F9~^BK;y0HN=8Sa(TCF6eZ;yTIeHh*&7pu7O*% z(QNM?vMrN7dGcicZ(H(mT?<>Ov9y7!$MM35)MFaqI1E~THGdBTDVM8xu~Ixa1FN`% zal?phQboDY2jJ!eWD9)?CvRNaT3SHF#R9T5o-4T>`i0tf=&r7Qi|1Yf!WzbghRZ6( zCML-ZwhBn2-YSQ{-^_AAq^+dzhE=uIT{?!5$z%3+^@BDWnE}<TT6c#n=<bAaFeQw< zHK&8x43{YYZWVheq()t849PKJ)}qqVZ|$y;x`OnycA<MCU0a14ajKMq@-8eK96QNu zqOi!wQp4lbnM;if7n<@x$u=PV0fd1l+eEO*tp%(Aka8g&C?aRab#}D}fBz2D*0?DD zwu+V%6aX3R-}x8vNZKJZTivJK<__SxtOI3{^~xQP8M2xw)b<c=1T{C4?h^<eF;LO? z`x-R@T=zQrg;h{^#B2;?1LO*j1)Hw4a{y3FX7j!@KOj}NI;0A%->ScZ&z`SV4DeM= zhw`zLKT$%UB#hLmvtJ!tnemE`k5K$!?&{_SuB6;9z5_Iy-~$q_*Nlv-r(0gPAKqIV zD>W%KmIa*sss$P>TY09f-m(d=bQm-e!7bypH#C_0Q0f>6Kmk~YQyBRa?6uiYnyYR5 zg0Fw5)c(MBGT*SIMzO_VePf6w%Cgwy@|O|QNGJt&f5%GeJQOrafPVhSaD`VjRcX17 zd5ap^p@%T05f!Amp#G@t#tjI){4MM8>ZajgK;oFaY~|XNwE<Yz$17Jsi@J~rTMQti z21cyN#>rxJ(P#v4b_A^<{l)?j=GymIV%qZocE?<{W2V|jx&efUKx4zf&K{8+>#oD_ zOf0U%9`g;TmVv16DeNU?y^ofcPHfr*6NfjqNv3C8q^DPpSta9t^1`Cy^Tu#_WHmg2 zB=16p?O1zG3~%sP7-7p$i+Y+sz>-SA*emD#S9b&myAAgMlNa1qX$n11-YX#ni_Th8 z@$mSPaD80IGWrYKmf)v<w*!^{v6J>V^gzYe4PX?G<{r*)TTe<Lr#m}WM|eubL#nx7 zzFgk^OTF7`vAtPibGYdsA3I@DchQ>HaQ6Ci84!v47*t~bn5p6Sg?e3us?*xI(XX=x zpi&bHT5SO>IS>}e*dEqES!34np^hd=j2oenDO_8~8S#*MRbdXv{J~x+>Bf2>d^a_K zR$@>AY{x)o+%DiJjkWu1M>e;$nrdn9Xjw##CQ1&)n&`YW)o`+f2h=G!%|D5WhyWtL z^8LemL6ziWgz0?z#Q;w(*&zu6yzaQY>>B_0nmh{+;WRhChETF`(`+uFy!HH@;m<F$ zOrYiSe6wu~WP--V0N-I-a|^wM?VA)E8_6uk!&DvZWKdubywCt?X3%!9pfT#?w6A|z zfB5O_P0{bBraGZ>x2J43+0<&&jPYEV9^N7F{ZJ@zYcNO8(`py8iz?jER;mCTk?@7Q zVFU$eGN)$dr=q6^2__AY6(w|PHst$o3v=mkF$-ETLAbd&P~)%okMn`Ef!G6WAQb`v zO!=gDN;N74pdER8?eQ)K^x*Ooc~fMj;gJuz?DWGxj;i6(Os*h~lbM+rbU=Uz8FZVt zW7ahWH9+n)goNv@`^9YkLLMLmjoPB7dd*13Tk+wPE5e=;d(s*jZ`JMrWh;oJvs|d+ zvGdlL`*N>doz$Jj{QUetiDahK<Y9;P^rv%(iwn1Jo~EWc-ujFnEhh#f>Vd3nx#B-x zHU+9Lf$AUt(lE-O%Eoi&#f~rcQJe7Z$>``5)^;5)745GNIV`F|+vQg4M1h%#g^)fb zCB-KsWKcb=4g$r~)?o7UriU}UgmK$BWfUTz@<7Tyohto<u<*sy_i~KqVaVxmHpPBp zy%AbftI9+4ZX~_z;06)EBxU>JKG3;`Ckt}o_Vdei24eAm2{ThQ&)Dg>lV(8@=<^1I z04`8${}rl`jMgOlMF-MV3D@6y=_wM>RMtv^a8+43IG>6kB0~-^xlW%fN)BX(*wj@= z{5CW)l5C`nm+fia-pLjCq)TCwc>1ulxmmmNQ?3T*2w;X-T%7-=RnN$2k7KoA>F&6d zzu}c>KG{Cf*#DZ2PGwrPF?~5kZLF9zapG`!IP<<ufoUtf)FXyuS}YvnH7@{(kGEEX zJ|)n<l0{OSc!!rhD30S`XnGJ-ZPHW<R)Mfa2WS!A5Rm5bgcTIXZj5sR64`nFa6;O8 zupNGM132J^e%dKG=$jV)&}1>3uajMIA)}Lhy@w8XwpJi1JzM~I1C{R8=hNZ0=5pGf zaOOa{Iayfb`fHk-kG|eG{%CCX1m9&E{2fi6tgk35iK&?vXQky2leDJ?CToi&0r z=X|*_PzGAC@I2QjKugv^srH15`J1uJfzLwtUFF*r*tF4Pahc~q$k%}BC<4GwCoSFc zag!7T20+C-TtUHJ5?WJ%n#OpHjpw<nMK;<rbgN_w_H=jGc^pfD`+S49JTcLleXZi` zX&cblx!#jd<t5OF67n_fj)NO69)W~*M#j8F>d1T*NZf`H525+V+>a$gso6u$qBDa^ zl_Z_1`S|E5Df>D)eu0YD5!}pd1)q|KkB8{@2w=TetMO9uJ;8|GC&}6L{mMeLxCr>f zTh>1q8^cN2O-$M}$*1+FHKIL6ii>x^bxz0^ihO3D1CkWLQHtuMPc0y2?I^Y88K@<{ zQ)I{@j(d8!T?WX;b1W;kh6CC4Zc~E#VO0zg+Mwgv{dE%7r$fsi)}b06bihk2WftY! zMw5#b*3!R0gRo`L*P-#9Pex2mNjtJhXljzUMrCBK`%ZU1!ULw8KZ=5HjdyPhZ)tum zd-Hwj7AO+9y(x+QA<b}|4a{;Bg$pJ4V(|R;(s>480LnbWDqO4UbBkGLD4+1%TqTRy zqdpatR?aQ#Pj5r@@Lg~qzY{qF_^|(;^lIe_-~$4J*nj-LR4W;Tb^jk5Jy3ssKjeTJ zFZVH^*Eg7>lP9$?GgD|RmrLNK%{v-KZf$5wV^_>)0+0scY#IZ%6I2xa)(G$;0nREm zIT<=JQDa99^<Coy*iT;G<zdUoGT8_uXmSjz;rUr@%kXLX%unjEhsUZ+B0sjuiQ`iO za4kMHWA)c@NAf2=%J@Poh6^<eKK1<&x_)baE#31u$y0jZqMHwH2L=QT4XfrY#wEfC z2}o22H7hGCuV7Ihv9i7&R|)}=ejuL*@H2B-41dD306vs}ps>qzbZoy^C#Wf#XZrbx zPVttn4I!6i@?(R7O07=;W&JHuL2EO;<4sNE)ap5*1ryT<D9;A8w6q!-8m~gg`MeZa zm^DhYP#5W!b?HPv(zPv&#C?wu2qy7oV%PuuG00cH&jgUGoH!YPN(OO?7D&6>#-E*e zl!x>ZiD+q^^%>A}Sx%k}?u%Ot<`fq6w0He5#%i420SSrAV)3k%VL-ZzRI4&>F8Y;} zL_XembaaE0?sO%Z2XHR;P1H4sUk~(t>fjJmQJ}dxEFvNXz7l-(OF<Au=V*c8L_CaQ zq@LgL?N42w%%RFun=N-YqNrV1b4CVG?g16+@ch+y3NN>VTzbd7EXDBVPPx%>1e?p| zxI5#kS9Dy9$0o@Df*#>v-qJZ#bFfLl=VrJhM+RQj%F?on<sxzNd^CU{qF(YZ>}hfy zr1aYr;0E;=TX!!^NpS#Ok^otPFKsneE%4_k0B}NpdysIb9@P5*6<UA&`e>^5p0TWP z&+!K?`{nk&R9{@i%)SO28&|+oQg=S>e<vUy?A@79w7-dt*9BO5(D_#H%;I?QrR5=@ z#lA?R1vL>x4uOJsb#q7Vi1>Jv@rD?~5x0$sj2kRDIh$M0!&vNJ6wm@zTmdGnNbhPr zKarw(Sg48G07Xo^`@Bq&A3IRJ{WA?fU}wl;gjJ_HBP!yC$*#vq*K<iENWBwtTOQSD zS%Sv5M>vuqLdxTsTUpsAwFmusfR5w%G}+|%l=E`quz^M;5*|r6xun@J{Y6w-Jmi_N z5oYU8^L5;ZqN3u`sd9<qCckg2SkC||dL3<w@&q{i;$-3yn+w88NdqDJd)kPc<@uHO z44_uk*wK+IR9F3!^(J6_FH^n--`?N<fwKlm@?iULMl$lK8|}f#6ip|pVSc}J-h-}k z1F`$iGD}x2t=ibwmB9`I&7)J#?T#fN31@d_Ck)`=lo2e6r1yY4u<dzkH?YHMl4!$? z%%x<7s{vrMdhcm@f!a3ByR59Ni;M0BBY+L2`N`{aA_LUQLqcRbP>~5yG(DTv)|s-% z=HFB9K!23#v=`(&t&F71$0@wK1syYVb)4zNvn2^y5pzV@GPO2~E`qmiCTbRa>d4rc z8*J^4=k_eU2m-a%0Kl)%(E;2<nh6kTbfBB&W_YUBEv;r;_p&sptS4)YK;7aBT^02Z zyPv3>&U8VPN4ma2*sc2$7^qZ+)A|u;MCOgMr3S1?(ZZ}uH@OM&v!fK9oew}EDWkW} zB6?LvXZ(c1D=G6Jb9KCGU^~?pc%iiOaVTBM(+aCB8Dc^A-xRnH$l>3mY9h@i59a5> zZr!?d{&r1OlVrjq$xZ9BTSmr^ubOa?=nQoq%#_bvT?8mVihFnfz{|~2m2&Go;Q~8H zN5HxINSJK+h9(M#QM;YZcTjl6(egxrbFcyGkQoJ>U>&!E*n^Gn`^zIslgB~#h*-MY z+oRkhk-+;-@;nJRZ4FgX$_89(pz`w8{qL6yOVEKI9cRR(+VLQ2UVC@%Zk89D=h7?e zYQ21XVMU6=i2j>?^ZGsz&(=J;B;hYN9}@<p<>)VSvvb<oT6dA<_L#VakwR58I0(#z z`tt$~L#+BMQx&B^l7_vwSOu;^;j!y#Z(p)Wr9?20iB`>B2oBx{t|7x9CYPnB>Dzrd z8JX3w^mvf18UM4S-au=*`RXOh0$}yoi0o-ld5T$?Vxs4((WNJh#F8b=P*GC;dwAPl z#clR#Z<+v%0~iCk4A#NGt(ZYA{?7?!!^&3F$~yB&@KkA`D$+U|G(Df3GG#_Z#q94q zh0>cX_9)&><0z#IA+D-?yER=SucH(8z1IRdh0YU46`G@SmFQMj?>2Y|aIwmrz6OjP z8m2iKa+0zgGCjQ>Wl?Mm7b;gbNZ^&l&yJUWv=Vj%3(|-O>QLsdewq&;M7MMVhD!W0 zGo!QpCDq#&0lelSx$YFlHf{XytyhF)N!?bzLWZwYaq8`UOSK}Lz?c2atQ8hb^as-< zX}V9D)IP>3LJ)z9A}wMjWA*VYE3TK_ruQdCBi4UMY%Oh~6T4*SI&7i86J`?n`2`9p zD{~x{<ivr})m%HZzJBMo65fr$U$L<~<jA&$>^@!7zBE^14-bexvD2^H*#ngK_?$O2 zjuvJ?YEGJsJk!d22r<-e#MJpfpF>Pc>{fl}iX%L}{1J91tonc(S6FyW@a4Be9u8jP zhC8?o+U3TFn*z^IU(?Q;PgKZOJ*@!B!aqKYBVJT}+Rs_eX%C6_a(+wJLQAUwByD17 zyd4^1E-|P)kXC!GHyQIYnpvkrhCGZHp{n~(+<QwjvX3Tg@%#6m*Q&~<{mxI4Y=3>< z`{yC1<qJ@eXSr&t_$UZ&+D9JbVM{d_!A@ZiCNXi9t`4QQMdcp4)lECiAd$~>Z>nmT z2l9KJ9M{gy&fb}0m6Vsp*x2(Va>aN5wS~I}tmk|1Xk>nHgwQ|@PeT(+dJCsguk2sg zNB#D-7e`(bCpG0omic)g<CtH``R-kzbarUT_W20~?W&?|Y=)}$d0oNJk$>_8uqY!O zpWc+4L^wBQfJ|44$A?thgVK9|&A10jJ71#VeQsp|1_>ch*3hW-XhC^fO$;*&Xu~q? zcg(JJ*I+01#dqMxy=Y%|Zj&eBMhlM=CK?=2oOdEk!l(sf&WqdT)DO=e;t}aujTQk6 zciW|7JaPuA;yj#&gb!}twnmwt*%~^9CCyf*4h5AJ70rudT(-uTEZlC1II%`EQKjX3 zbfD2!Aj^+7w~!-wI4=^SqQ;eDsy2StfUZUhOvGAYeTno^=z_}lI>6U^7FA}5z?C8* zPf1;Z2UfId9jp3G6Wo?I#y@G!cB4!@8!qTODrnG=*+7<wgy&2PTMkWG`5q9e=q7xR z8RzX0nM+rnvwz(;K9H-cK*%bDhkv;BaI(h5Ciz_(XyWhFePuaQY=9Zxs&-jhl}9S8 z50n<poNuSYbuu$E$-O7puNGtVGm#ojqujHKp9omb63dS-sW!=T=5Moxri1UN{~E1z zeRE0d-xLdj(cXJ(?67CNg8i-cO6e#fHLyn-A6%^sj<USXHTcz80Y2r$8tT=f{VxU+ zEM6hFj;{XK`;q_hr&BDzAGmsY{~J7V84K~ddK^Rkg+=4wRR3T0=Yl@s_<UP%d`l|l z-?nk^sa6Lmes$6)-tz4;<#;Ab7o>h+pl}<DGskP5+Znaus+qd#e@ywOs&2!X2{plT zSPLnpR1^`F%*wp_v7c)gDQnsl7<0Q4l>LcOv0BE<*Re8JGp(%d2yvb`73A`7Vuj)6 zzJGTA7UA@L4Xe92jpCAKz7?M%YF@U8in2ya2PS}A*3r=?>s0Ji<RsCktK^kuWQf?u z-!A0=aigwjubYUBV^lu@@C)^_DX%kE`6+4{!b;jKxq$3^@ATCgbxZnD0SMfRW2g2u zXxjgl|8be+XBvQ9eGQyf(QqRqBTIzC#oVs;l}ZLaCFD--$0Mevj9OUFLE=;0^lTax zgDwE{M?^-hlso_<NqLaLNJ$vz{U$%HXhQ-^f=8)`8k@9^9<ziuieabjy#Ng@t>l6* z;+bOKN4)^BgkR)0Lq;t#?eo9~eic^+vMOoePaCG#ce^L_>(x0^(XZ$l91x&4d4<dt zdi?k?CFDNF#)*0o>vI2a6(IL-vgRNrz{kJB9^a7k@TgVh$f*8{*C2-7g^vv7qNT~x z)6$mtZ-YU+_zq+R1HYZ1`X8%Y8NHTVHopcl(bc9D%7k!~Nr|fkfVEGtuR&XXi|IE0 z_Y=a$3h;STSZEQJH$!M~#|?(GLt;H(pOpT^fPO|YJ#0AiMTSH^{P+sseIwC1oBuwp z9sSRLdt6M38=Vm^-73Mafam|fPG63jn45}J|EOCIX^8LM9FJFzfoUpFtT-+ljk&gD za%F!}v?rhEFkV!HB&r2j^0>A*`(H(7w;7E=e6H>AR*N;dZNXU;6*iCvvw$skuGpI_ z5oTsL=Mpoq7Dq?%&a=@n(6Q1f`k`_pO52py9bFfqG`i>@(!=^S=#7A&f-{ay^t<=0 z@HkQuz1dcLFz%0cz^>}$lvJE&?^j&tDR0({<uO`;HB*ST94N-Wp>KH0$)GYMBdirA zlv~@_KY12$JCcEM_Yd1sXOsCF0bJ7@csN9@TP3ouw^~%O3bxAz?4i#zXO&{jho!W( z-0B?`?)??->RbFvnK9ovR4*JhoZSx=)C9<Fr1WJh%V_C36T*#HSnXnhTQk0zLf-5| z=smw*b(40?Rg`6bKql4f22Dp?`KWPejYbB&@<3(Biv1E{RJ%1ke(ZJUH^d<daFF4? z?I1#n>}F$z5g4(H56){^FeqfU<=#2|3R{$+&3s@*$%J}oTUV>_Cp!WO-6^+Ur2Vdp zT{#5Sl-;6K)iji~xHe(KFX^e>8*T|%Y0~9+Pv}T~gFT}taMFw<MW}0dh^gr5DU4ZI zB<Xdat(;8)x*6D23fx`{C%|<&gcXVfXc@8)Q#8Q?&JOOi)~8;w@UpKGyD5uiJ7VdS zwvidCT<#lUNf9qPbYC{~ud%H0tI4`^A1{qoaU=U>$J-)%pw{o%!xkE9rKgMfw|{@! z!4?2<u8t(PefcB>r|8!m79Jle+~*9a3Fgwz)?~##lnz#Us;hJqC9t|^*lXdHhDT+n zX&Qh2%4R>G;zAP@)lKn=Fq$_aQvbnGYFc{NXQ7A;rwB#KY8t52Z0(}i9Xf;U56QvI zd8E*PN`9i?GKilVj0~y8t1iqed}HKyMie8_F4G+Na#(}MU=L@vsHjIQ-e7x7eu<cG zjyXDSyE!tP=N-Q{PY8#*yJ}e)O6Go0gazjdca)m<GkxrT?%D>nO>JWY9OmSZ7p4zg z%eWn>ph$jaN=llHy$`+$jd=7FQcN2~N>7heYXpP{7D3fs5&^9=kr8USInAv1irK?2 zn3;LldEaD9L<o7SN*&jpmfTw)qm`DC3=UUqX%Qi2q)|Vv_x^|Hv(vK^GBDJi*TBK% z<7JlEN3*u$hIT`lvG~w9_w_RcNK;YCjGcy6W&{LsNY_&f1yB9$>}0YICxS+z2EOUp z^G*rbe5FlRKi21Ty{FE~$YbjR$sIKf@~Ca3if>veoDlgeO!8Mp&P+!r_bmD#w~VrE zzLvICoKVdQm~r>OEUh)VtD-aALqif-7OlE|siwt+6Fs3H@Wb><^Y!*MtSvA|s#!W` z8*<PYjNM)XlWCl57g5wKqJB3xvm=0Aqi6`>s^2m~jCV`2hUQC0H(NkRu9@;Y#kWZf z%b>vf;}U8{1AE75>;cI%_aktm>!aJ|IEQl|rg)H9eP#1+f+R1>?HbV%M1|2TAx1ab znO;iDeGd%_k_rxkKV)7STZ!05^lA@_ps8Y`^UH|F6eA3nyq3M=pe^F6ydk=Oj#Nrq z+miUNZVNa#Ro`dREbN@bL1CI~Rq}a@k{XOuVUHm$52Lne$41r)=o0YXR0$aU3ISQ} z<tL9bR&JLss)oaF2h5fUq9&BI^rOophLXGw-lMX01`f;^)Fv-H-*B!l*bVEui>Z8` zSJ){I@2Ia(J@zwlpq3JWRb0L}G#X17At8KD2{m8Rv1mz$tzFYt5%fo`{`!rziI><p zWMMfB2tZ_<M}2+adXqNk+~DDz@BdI!b~L<p2#ml_BQS>(z(za4OSP(Lu_}EYqB{<4 z82IqdXv%-(0<;9o@M`ogWoHeA9_T2~=4krR@`P9DatsSV2gE8-F|vgIV-Xl|5I@M) zgcHo6i4D!xXi=T*5s%QZs%mkhuj&j$z7|9FL^|#!*00<vcfF4`COJE7|9t(&^NU2G zl7u>SKe-t`?0u2*1o%43;WwzjInUEbb1ad`_avH^Kk57*X88=BcZK<U@gk~D&;DpV zcv%%QZ-oC#!Uv*dDUWcTt|N}HN%j04bAFw<BY>uaeWd=OZther%V5=I3VNZB_dJtk zgWDj*Lq;80-WB1CR@q>g|N4Y*VquS4Iwbl&TEj3BQ+$nO?*6S0UnMVs`xJ~jMx3{p zUWq=7AF-Td$Id3F5Z12eZL6b@oV|s?9_qcXQ}QRNpep)<yA0~v4}BIW)H^nI+VhlI zV3_BQFgytBb9k+&mW-i7z?6pN9!e1LrhrV{F=H&*Ptue&S8L34Gc(lCVIvd@7a)u> zlBbI-)}PBIj@UEccs7rQ2mQR8ZzK*!CMP^18!i9%rJ}Y`v8D+I$B%}SM#qAFR<=+M zab*|_cH(aHVwXC-<Z=2x8iJCA^B6|o*Mle_Wy0ON=q%2C6r<Glpxv8l2VX0fPiiyU zMnYwX^baT`-f{6N_NUF_yX!)0?P*@!^;orcCw&m@L9;-T_D&#k)1XUSJQn>$aOSXN zOdDJem&+LYTM+~Xo#lK?2UbJXH^fB6+B+DSO9-F9vNmUYuw8ramw|`kLoO*cP%Rp9 znag!gPeaO5Ib#f*<ruiM>#A7jy1i@HV<Uk-Wt@-ejXIL@TPnWthA3zDrBM`^=>Itx zeHL^>&1eTc^_*-w<R7!TGscKF1I#S-@Ae)tLaFohq1KQ>lC1omP8LR#*hHK6VkCar z!dY|6*@y_JVp2g%vM$I%wSSw4sib=QmsD;fD;&bI`>#zrZ^j;ZIYeNKaX_rYT%Z+@ zp|EC#<N88}j?;%@NzW))uE8@Tz*);HFRr!@;!T_ISc3w6Tye-X__%lDJKtZrub&$& zRgSiw$b`L|k$49?otZ>fa^%Id$Gj{}_!@!lpI#C^Nc&p)d)VFJu73uY7D!a<XYXAf zX@728Gd12=Sa*EohO%JYNhKo=;xmGq#!G~GynPzNqM~u>2M;4?kl`RC9!-jePBNFX z&V}=7U$*c<%AYVXIDLxLzYlX#vP>d^E-4fuD4{Aqe$g4ft3^!nOzB1zT}vml>Xv%v z(*&|2g3Pa;7tZFaC=(mwR1r&-c-kA}%{r^QEbH71smbVtt9%#&dr!%V1$3)xt=hhw z)Oec~B687F!FyB99)N7>qR!)?W)TEww7OvJOEmvvxN{18>JY43?Q9K1{J2&%bt!Z6 zB`(`Y3PT1yK^nnqI*o3`!;f}(hF|O>3PdZGUplnZM@D$K@{>pQsoB|!d3PF}q4IRA zCZ*l^jt>!z^Z8i2H!1L+#`->uxL1~s-Ut_ITCu;}TaH&NTArcog1mZ5T1X0Ar<Ih= zBv_TaLH$d`S;tPtOOlLkYTzH{JK6dMj1L<uolVqvRr*t|!EH=EeB0)x>X7g4bGWWu z4>6-LE8){x{4Uix8lfw_Tc7cDnK$@eR(Wy6Ko0m?oI!Hcew-BXs$<%SttidsBv#?C zUiR<GLj~_tIj?spIZ?68X4#bOg11o|Qo7bEqM0U5mdUkL4{qQ68<E9XW3e<!<8PH9 znX2QzjJPF(3`b7xTMW^dKF(+nF2<U}V2hBB9*z$kBjal-;K4nkNQC19(b2SFG0Dah z;D9z<ga6X~9u_iqp==eEsmdctKJ)RTS%=-w7BgQ=H=V1bO6ZX0T*q$NqALC;$eU1D z!)1Cy>EB;c7DJgH)Iki`mnd&5&;{1Nn>fg5aZI!G!+Z6;#g5?fkBT0S9+pmIlQfbY zoh;Hw89IZ~!K3R3;!$LVD6l{M2NGiqWaR_ikmr!W=e20L{F$4TC%Ah}Ux|pvN?ZM& z#rGF+T1}Zd_*5Xu)$zQpem?A~D>Vn(-TO92g4t+yfd<9}k4Rubric(xQ3ZD^&W3QV z;lGqrFD*L)zRjJ617kv|ZxBx+B`#FT*2crP`!(w?uEEhG?-@$|){MBq(i?kM#!c7A z!?Gh}ldks=F7J3cAlu^Op^Ebs>O^E_!*Ngu0kfOMbOpA9@U}DvZF=X;6oP(5iyhAr zAb;r=O`wG%e}0!rV=71mJTg3-JcTL-zv8K7fKOa%UmzDP9+6)*KN^E*${l?s&G`X( zGs2g&<kF6&l{W)Yg2*(^-9a4CSVn9qu4LiID_tmWn9a@B2<QO0gbdndQOUOua~G*1 zwatX~2juc&4N)S-<Bzt)sLxM;tLM{+HDrQC3?k9}@_Cy8FT&E+CA$e0ZiU%;L}qrt zCe1~2O=XI%PpMu^lck@)VK+$H>VX5n79M@Ef{bGbRmFR`Z1f86d+L1PI<*-@`$|jY z;Eg4%S?sFpBuKBTDWA>!f$k!e_Sko^UuXLPYq36tBt)ehk9tj|+!>i@e%MzD;47Pf zZdy7G4sdbk8!%`2jGY^LN|$z!Djcp$PP4M`3@-%2UrBQsjXzZ=S5`A*+7B^<bidoN zQ`k8*fiMKH?|!Mg+w{4mP3&Yzr!0mV1i9hZ?_+%a%;oy~N(P4V+>f0-Xjq#{fyUK_ zyImpRe$Z~9XdietGiJGZf8sXTw+S4dAAb(8+tDZG-b5g_0rW=fFYYI(>|1Y!v3~LU z0#ib~4@cdX*tZZe-VX`KN~nO?ba`^KgrPm;TT%R_KQ@}|GGrtPnfPmaRpGkLm_3kt zQQ-KM5Nx{JCAS^MEPc2%P5lG0r^cgiWr5$u^i}k}UHu;Ej{H65Xmlp{1m}fBMxY*Q z!ibXU3;67#;H~sN{eXS9I+Z-g)nI;@Lhk2UKYMl^eCVyv*Vj2x87+2|eEA~G_!Ojr zR%celDzS}-e?aU6Z!Zy30~!6@t3xA&ysXoYSwu`s(&>XA1$JBk%gd(e9rKR7<@(r$ zVVqSN1fgQpt5})8V`oy>P4fNO{npCLx8MpX9W(X6av7~N7{lY^nTGDB_dO$dO7Sfn zYK9HXm4n5UK!t-j3JwR5wVEkw><{B`(!p-$N=dlUR>3k<|BktSG)PeZ8$wCZTnVpz zZs*BU!m24&273U)ot<l{9>l)lM*_vLB8>uobd8u05fN!_ZsoKUprYfITDfzL$fs!F z=Qa2)=ZVwXb&zY=pyz)58X&zo^@}C$7`~3hCKY^&=wUa*!kYdTjafR^Xv0Ee^uGs& zR;W>t)GxijUZN%69u{%zx-&h8Pffqwy2^$~vPVrFhlX0`3n9U~q=a*-_$(|%X)^ws zP3qh%xmwfh?i=9p3#N-~VZJQR5z9n@4yS84#PBDgAv(lYt3%NEz7hM{I3Vf<^LzFD z-<t^gpC?Vn0_Nt6`PQ_+Rmw~_*9fh`n9%zV_HqGFwehxF9mtnSO6LA6@{S-PAj{nU z3R0L?&;LJv|D&uaHqDupRn&X(OPnG*GaG15>PtzLAVB(Z@9%opp|K(9P|ecM$dkP4 z6RR}}02btR-TpaVE5So5mp$;9gd`6;w<#Lf+h{*u{c!4=Y&>9K>)}6FhCHNGQs<j& zxR_QkEq|~))1&(;XI!(~ZKH<5gXUg$T;}21*=>8x2K6cg1g+vv-cLuR-+<p{Tw2FJ z$LTs_QCM56b&Gp$h#R#vs<0En>uf1#-PJ-kB6vO-#KTjC;8IjlvQF>_!Fv6@BG_ag z!5qd-wLqQe9{oPgZ~fxId0N(Ji-lEAm~~)42)qqwwpZ;f($mtZRQCkXlR{a&?hY<h z8emH(ZNC$@JYUs#);QbQ0g?6bQtmn5uy=63JUUtavu*3o$j<I=bXO<COXg3zu}=%D z_4VV23(n&6MTgJ6d<nEMWMM4azy5@XsPlp)jyotIAUyl@b!lKw(7?dJ?A)vek`bVO zLqkHWrkS;qfby>4I+`P%;Q|cYC+jQIC!E`N;-`{gV_hXCDiG5Z7Z+`N13CwSFdS)3 zwGzvg{spM&3ARFz=8zqFku8_Kmd=xmF!wC~qz;J><y(xgzouelW+s#hc}=}w(aO0v zs}S~Xr)<dlT=yU$V2%~DbujQPWY_f7GhWBH`IGFL<>szy-I36w<8L(3p88iCy8&~2 zPV0a;V#hOd&0)Q7yd=YB=5jk7G0fBSn}2t^<N*2x>b{m8LLx7?F#oQNj9PxA@bqHC zWoBzIwaJK}^WA7xvbUFO>)Mr5ABjzo{#tV6%?9jUzqM-$>-81=5Bzi8_WV^iwV*Ii z#T5}18?rTtpQ^wd3NjT&Gn@UUPtF8|=&jbZGETsNEVvdtAeeaC5+-PM=5;RLMmi*| ziM**6F)Jzx+06oV))A=t&^@-gv%`uFKm&Dvt9E>RvtG6Q067MFE?3{zpO~+VmkYKx z&P<@tbAdK0*5kFJM1Y^742Ex+Te7He^J=6@*bA6auOeo{xvebP<OWj{KDS)`u%gur zxBmcU-+@iJv}t?qx78FP6A?S*o)T?d{a?9&gLh}6k+9mJCOsC$h=%TX6setvi`4>v zEdZ77vyqvJcX8*%&4zb&c1{nBHpk*A<9VG{KMrY%h-nm7pOwJ!h7<S=Qz{mbRukpz z3<50LAZaT6WniVV8yzj^YW|UtfxS2-#TIPLU5<C}bk>Ngx}RDy%&52qT<v#9?0(;l z?OsIF2fHtnb9)^xpk5fO0p`2lz~Fpi-1w<N;jN>0ZLHe$R0hz+r}v(cd41xxT6S?I zed9J5zg6qr`9o;@kCLF(WX6WKBQj}-l!cD&Bq|cu44s)-aWkks%5Q!?<0tEn$5|!) zfQK==sF#?GjCPA~G_$z>@93M-l1=w*1@+3~6N`a<MNl{ZTR9l`7s6B`&s79~1_S@$ ztTgAOYx~d7utIL)5R+t1$JRWX&GB+gMZ~m#yXhLRjSH7K!%J(ylaq&_YAJT{4|Egb z)}U3(VcQ+<?_x8ASeTips5?FY$e`mJJ!H;st`BKh7SXKth&*A5iEoTn>vqeeqkoTR zJ5@2~pI-R8OrNLpKU~?;a^3O`Q|7FhrA=q!-;7Dejt*xETc-Ya$L$+t^4<U~Kf)L{ ztW9a<BqygG5D;k--4`>+Y|?<NetF!K>^l2cJ?HIBy&mc?OP6JA2WCveAGD4@XnxEi ztBHE;Sr!PFuU_(9ivH8%+MAy(Kn;ZE=Xjk7GDFoq$KhL|ljD}oXTCDQ^<JUtawj|4 zcnik#G7=KY{Z)|&8PFuA;tXo-`>Fn<X^8jY!eDGExmYQuM~u;WQ??sJ3vtv#fm}IC zWD<-~NlA3~oNMO3JYKqKy~~K{TbDN!!cW`ZKd&CAZEjNyE<B?#b~`vWbAR(1&a7#- z`{(oLc+v+;6Q$Ft^_!o8-PBCp3lVGj>+doE_r5%r_isvNIb3u9QZVQ|s<*+|>alz5 zpUk~}=Fn3y)_EdvLSo{yF`1jk_OnDIXxD?<<e25eJokp$#sh)<{M2k1AJ7M}_wcwP zCG5T0gQho))q3RJjhc^}LirA!LFl7(?88Y5;>Z%@mNd6&Rl6UAhr=UJ5}#GJu3^lt zsf1BKJnT79B>+_UTXyr0K_B78`kpH|HTh<@H!oz9yU+EZ#&G8Q0vl0*dk>|EorW?3 z#&<m*zKe^6EzAs7L-GJPtP{$)<-XWE06P271!s0m-yV9-q$RL4oH2SYs7QTP-K4>; zcrOpuA?0CdayuRx@jiE2)2z>ubV3)=yh2>Q*u<Xq8D;ZF24NU(UqmT;I-AFAY?tTh z>zPj$!?GjLiB9f@zW`rQF*^Y(>8*7w1^kS8L)Q)s*EMT4Pgf70k2XMCaHJz+V&0zB zmgc!`B*?#G8*Lz<0^jUIhvjV|{_;!L76ecWoG&5cFC1>FATlx>yQ`gcCEX5|Q&(Ad zc~3ggbo@K}4xl<eU{ddl4jCblZFQMG{W!rpJL&Kbu0ApVkW?J3xi^BI&RU(VzB+e6 zop!`cNTk7S$rwKjP+M7u=$fg*F3+(cQmFG4lS<r+tJjW3WBP+fye`kfB!Z0QkCzf{ z&+HI#i)?C!fv+H?z7q>7L*unZK^dwgy_rgF_VG#Xc5W@jquG-O4Mg4X5Qm@p`(>bT z?`e0!1oUCv=y(dSvnyE4I(ByWRy(bwnM;xkfc~d>e+q6;yi0QV?cJTL<2lFTx;|Z} zX)YxNVT=A`y%5LwzZ(_{OWnx^ssQW>?EQYOx1p@fqJ2@0NKWR8;?&3XOSC9-)xAR; zR6B0IwLhw&@YvshPE=S}00u+;^{zL{+KK$1e%w%Fs#hPoY_2ymGn(tP8QVpJiz3QT z>Dzt8qF+STlv7*5(7NqqQ`p8tbS`!)aByNPfx<#ENRb(WlbGP4il4icss)AiHymrG zcF@R%4q@=L6hvz|3Qbg4p$lZB3reb#QVc?Q%~whY)yH_o2Pm)ODqD6ry?1|eP;S|$ zMKoSv<p2y_@a$+n8rfBAw_6UNHpm76>0y_(@k}NEgCSDNH*eMsE^BJy$O40;uObP$ z@7ObvwsHmb_Ht4Wm$bPq4#(m=Opqs0Zo#6rY&PBocYns_D`s$TaJXJ!)M9O<prPS( z+P})x^@4LiCyD1%ug@EK!ESx_k?j|2_0Y=;|56IqKjc9zRPt=~%U?Xz)oYwLyzEYb zfn(CIsHvf`TFIWCCn%w%wcg>2t6JJLn<-r|Om!7^<zj=nAGh7yAo4eF-c0^|E(}DG zq<gwLllD3$%c2I|<U~wSeDg6g`C6F*0-K}h54Wa0589G73Wh;Tl+9$B>weBXgw31G z@ZOCu!OI9g!OQXC0t*xMAJ5hT1LnZm1tiuucPIsH6joY#0B{|qjh$HL&Yhj1t(2=( z5yGyD2c;BpohyDsY3dc^fjDfBq<-(hA6UdKU%lC*VsF6bfJ@<d)*q}IwmG%7Ub^~P z4c2X}2>=<=@WS?vZb*HWd^+p1X*74K9)K&pJKCO0=CbbU?DPW1Vx2qY*{6O<vT;=2 zr$^wtH!tn@0@q3W)VPz#dlBgj4)gQUo}~#7O>})_Q?cXmh{;0%0ZR7NIC;oNwe4ZP zEtS#_Q@4(<uH0p8OnOA~a;FkheB7OurVjIXkDZ5Bp<WCm=ZMPyPt9^mO7Gn}Sij|= z;029jWJM1+KNcN%L2VlN#NCgLOL-biImXB$$nIow@xpdPlri0?ykXD7y6y{kHD{5- z&2;gQeQwXdu%H^t#!P%0PmU$f+T2g!J^m7)R?42gO}PX|fSAac>Zav|YtWE6?4~-8 z`TX|WDJfI<FdXWoU1h!jJn%A4nkR2Gy8xMW(<<lM@a55fPb6Sxau>F@SImT#y6zd@ zpT$P0q*3=LLvKB@mXfk!+_&V?G*%g=B>X0d^et$(+?fm-0Kk-v5v{Q*C2>Ign#-WH z70C=-6%0Jy`iF;<W^!cw=}va?W$nV#Xf))!574;m9cUW<4Z3;nDXYdhbSBrpUkCtH z-tK;vf<n|xwJ*9+Glg0ZMMXrxitBuMBwZ+G>jlN1q*DqxS;>0Kvr*7~@4@RbeYoa5 zNX60z!h*+3i4#>yxat=eRKb=v2mJ)MMC~tK@CN~{%qxD%cNf4*|J}uSFJpJ_-hq*^ zI`d-FI2i(Q0Ng_SR!$puQdHDS`S?uk8r%7Q6V5l;`xK<)O+N#-?=c}EVbnfj?;xK| z(0+)W4q;7`-HdM-T+@szzK7Ipwf1IEVGKuKvQEHYR20BCO7+rR8`}g7pX*cc(a;yi z;pI8)e{FA&r^>FWj0-0lgsRsXR*6Wokvj}Zp3Chck;=ltXRlo6B5?r*$9QZP85Ah} zEwK*jB6^Io^nmXacxRv`@#tNx_VlDFhx67r=4dW##jg2rN&d!Ix%3jB&FM^>(+rDx zH9sFcDktaVJ61UuhGQ}nFCsJCb`@3NopU`rNBY7rH6w#EO$KXF7;ymp(aYA;(7mak zqjPbR+{t@_$G;*HnOm*bK4pEli&X7wRH$={S~H1`g$)4|)KCm*=RyQ7iZSSCXY~{F zHPpq0NgkYKc2*x^RXLSZ_x$+~E`S78fL%le*k3uX7rXP5^AG}J6w|RZzg<q&Jb4B> z$%IRHY_j5Z#(#i6a*_A0aPcXhS6F_&X2cE+WWCye0bM=+&x3pZ=gDdOk7DxwQ$|OW zdNtSYL#jL`tx;`rc9&=-`C9U!(coLhVSW2-={7u#;$*=G>tlO+J(>sWu*>QA@M0UN zSZh*U$qzuvl~4RZOUqKaFHLlhh-JQE9*K?8DW>Znc`A1?2LIb!OZmL*Tg!LWC}~d4 z2z}9GHD;orW=Z8ruk$b{i%zX!M+u8&MNU=<;E$(I=0A#x^xxl{(o_^3deST+&L9A; zp-0YFo8zN{hN%&1><llTiW;r|!T<+bX^M?{7sZLY2j{%K7rESReYr$DYWhk{?EC{K zXR`NBiz<{wy~5H}PA=3u4x|Kd@rd6#E}MOj7t~%M!6xTkU!~0kMa;}{hs=k@H6=F< z0?z1<!z-mYITa5N>FsnN@ar)$k9T%<`uh5$4rzSc*EU>qKIv%yE*f4n$K~FOP(Euu zgXNEzs@OC~`@bWt`?=hy9}pZ85)vG28CV4o#s6n?;{X_re^a3cy9x;AtThc>`IC4k zymy=D<ToeFrOATb_LtcgLMP;Sa~t}x=~7ce@E1WTF7PWK=o(;+R)SMgb>VMHIA%T& z`#dvfS<&3qwzd)Pg&CR^C-+>BhI&D8Yrs)nGZ#AHcCeni{}t<JJ_oM}V9K!QXp>SH zf^Mj;FzKI(I8pI7;F&Ry*}f%pk*!+-{j>^y=e@aS9Optt*0NJ6{@1yQn%nsV^wksP z{QFEn(jq7%1oAfnG**DvXwELa8zd~ES`+EyFDP3>l8??01#`9?c7IT^)oyDE3)l8z z@d^KVkK6P1RUkkpJEG&{#Qb@-hNz%vjEwwa&qE9T14uhkiJP_>F6}yKqsjBBb%sQM z2GWe@VPb{Vcus2;3MY?U339b*iKC~idwzFD?1LGW0jz{Q`%=$B#Ewtw@#mH24NcHy z3V7Ck(?m@Y1(*{Cn05erY@*jm?2Jc^qMrBFgyQ(T0@n=a5txHaFE7Uo4k{B))6_;r zzQ}Hz!?c@)N2U|^zUkrT)@!86E0^%A1O0zNtkp*gs>UyGq}-$QDf;jf8<)L=PA~<S z8B(&c>byKdGqNj&52f4(><D;oI6H(cK?>pD9)Z8RMrbzjH*g=SAM*h=4acp2D1DWw z_3r<-d3i~4$jCP_hXL~Lv@|p?*O5g<MU|E9rmwQHGX326^96y-aqyd-v<-+T@Q$D! z?@$J94-o9HZvBgQ43Q)$(&}dlq6IJvMSBe@>h$?19RYXEnxY$p*GBE;B$14?^d@It z18(~H-Tlw|+eXI6ukBQV?Zm*qa2FyWC&v{_)!FLU)1i?1*O3=lxw_hLaiW=e*Zbml zWN1W-K0NQP`+v<{M07Mr-*ob}WoCWbNbu!M4OwBOmy%QU@~Xf4neWnOHE@CH38LI` zrNn)+O5RW4%EEBoR~Eq-IA4AU6nP_N-i!+jH$q8q-Vp|ew+AQ*mD>-kx=hDre!Tt$ z>M1K1eb?r!feE|XFSMSVd?N@933?OXeV0?WSZ&@h*IQoukpQ5ayUTen7%88-{U4O) z?Ry}HZ;e8ma<m-dKOg*&z?%N+WwxAL%H0Uya};VGCJKiX=#)B-^&2q;gJJ{r&p+_e zL5`T0cLK~c{|D$$U_v3FD^u^4)BVW5BT|W?NBH7Wcq#j_|N9O<e<3R?N5QU_dCbnv zez*Jy$Z&Q`s3|LFtZod*FJ0`Sk$20>jL<|pPFGmSt8cK^L83;ZYia1{7zZVR;q!Nz zMMdVW{@QRB+?7y6(ICRaDLiGsD6vI5@c8&@NlFH^t+B_m0REP{PM`~wmj0uhfq-~p z9Ov2;8J`Q|An3W|Gww-BZ^XYcZk#_aOiIV1Y+l(9iC9jbl@4i0n$+1BB&!tYyx9*f zwW-2X*w+ve-Lfh|ydS?aC}*5lpi!p*fS+3HE*7fVg2&_EzuzYOXRcNI1l>cjRdvcb zBmrlE7B(tMT<~1YQ}3NSo(T{9g`R(zJ~O}ga+9t8?YU1)3w?9L<t87ZRi#ivz<K=$ zgrjx54q|{_x(r6bZC!1#Iz|8Rx&4&W$k8Eadl)U!`=FdbyS)HN>;RJ`|6Et9{JWjX zyNo=a?THr*I~fWl-XA^iHyA5fV2l#e{-l>JM<x#CSRGr^F?Cr^1!R2&vN}#v^CZgM z2`-yF;MN$eu!=XQK}REV_Kt1(|Kma6f~~R$nFeflDI>=YB@SL^J8joDi6|&g00cF( z{v#@CwA4iYQYk>27m&c)&9%Py&A;aNG>H!rrDSMiFSQG2X^;qZT8uR*v+-8r6n9_E zHMw{9UX9CE;TKuClrep~x9e1lj8a3>oi?f}<0Z`BS>xa8kV9j45d47hGo;GUQC0`S z{u7{g2`_OY<Kp_an(F}|9*ra#De{X(FDMFO{oMwyb3lh*(0KnpOQ@UW<a)1VS#>GV zWg2j3S6ozMBBa|6mir7AyQ0tI|4}8HUrM%L?KKY_0C{P_<N1cqA=qa1v22IU82hEu zE$_E_jTwMg+`M^vZ=%BNjZvqS%QO#WSkoq6;Is+x0J8^Yv1!<o+nTNe4QhG9@9mlH z6wOBsv;5Sk@bJ4!D;W*KXsY9>Q;IW}zzXa0^XXIaoK@Pb+H<kYj>S003?Qf}MyOhi z)d~%D@hm-n?qIkXbBI_^BEU6swQ&Qdp6U*<*yieJY2}|SEG*`h_;mGWIayq0{e*!g zpX@}p)rd@K+LzDyO+Ws592l6-1fuLwxcF>Ec9KWhRO$w~>wa-Zq)kx_OVhVRPV4p2 zs$|c4b4;AejHmbhvI3!C*u2z;wo6?t8qfskb9^)Gvfv5v^SgOa{lJ%Sbtj&+#uC`q zF#{{>0kW&RJCWO42*Q?&;L}l12*08C+2VfZj~|cN?x|RWx2-8~66_PH>HlpMb&;$3 z;P?DLCN6}wM!CbdsTwU`$0}0?goU}EV`8%tcpcGjmVqn*3No^dnSH>T3IxBa9HOP5 zZ^yXtj}t(QzSB$)g~Xj9kmvW>WyluTnOAD^PgX6K9Ru{NfS9vE+hvM#s>VqvRgaP2 zuX7FQ1OFEN+#w5)cLUM!nY5ow5K(sz@81188D$r~7!K3++=b!bdegzOr^%-a(a_UG zj`j`byjJRL1vz6*yygc|UBb7q)Be`fYy;Y5TxxDPRqX_`TsSOfz%86Cvn%B%cb$I> zsasvmLna8?Seu(84$40G9{^2}@#vsM)p2V3#^yV!+D<JY+*(LsNxoRQY^9-opfdt_ zIc0Zh6>(Ugwu71RzAX5{LP>cs?cQljUOV@@hM2pq|Lck2>LZqa9d=w67K_^o-qr7c zMo0DKXWVo7(o%=cjo$7NMi7Q*y6><5g;tqGvi)2whWcOt9lJY!l4P{(VA4q`0<?dC zoZ3X0nUb0*Oi;ZP&@#F*mycbZtR=TE&i17ru8&!aH3%Bmnpus_6CgA|p+u3=VLUfG zxNaX`y?r||GA{3L?}gH4B@jLFy!hgW{=grsis!d)xJvLJvr5TaV8-k9enm|^ml>^7 zXzaf^Royv<Y&a*Xv>#ZMjgWU*nH*~{-djx0maA)9!vI<kOe!wLZA-oi`yqtg9GwdP zZfgk1DTd-v<>Ghh2bcB=u=ICBN{QF=f!cr(Yi@3CkKmyrM6<?u?W7}t(Z3EU>Erb2 za5IuM{*Ue3tVte7(gDa#Aeh($|K!m?Jm&k_Af{h84Og)UEAaeD8PdI`0*<<D82}b? zJUt)-%=*wj52h~e6jhvz7@C-DO~v;C(vqJ-leF$}JbMdkg*AG^`G<gD53{(2gp+u^ zqol0=;LnIFSTZK%eY~JbD5c?0!Rwd~ic-p{JCmjIpxreeQ6L!*;Cl;C8M6&S$@a^y zN#1%OOEocX2C7wT&v<5ikSCipTOXe3`mO(WgUh_BhS%%&ih*IJOzD#3Ky!`cnFKx@ zFd%owdmqTV9}dWq%omG?QKL6__V)(_k3Sv6NVj?JVYuh$<=p1VV?R^tka>2omri>r zLH;hWXv*;{I1)-ZZ3#b%6Z;UY<<S>wa=(1clERB)sh|BRuJw4yLiA@}`8!$;BZ)5e zpT9l=?6#V~;1I^GA!GxGU*`@t&>nzpql^<|3!KJ#OA+Ij^OU|e)jMv-suzEn#uyDt zun|0XehPiCcscl8>Cr8~?zc2cD-EuxDR36=?w3|2qYZk<1sFF$ra>I&thhIcYZd7q z2f}6wiTMqG^y9JM<x4+pd~|=}2^rb>_6MU6VcOist(8ZWpqvJ-XE^@J!r}r0^t94` zE$=mW@uW{@3hJL$S4@C3Svd?qAS^}-^Y)wgZ7&4~=z{^18>ygP#My_uA077_*o$HS zc@TJPdw02%@8!ombNyxO3OMgV`K5?~%#CnRi~YD+wsN%tV4<D%_8tZ~z1+Ngsq^?- zZW#9O?x36}Ce~WoQ@0(ym47RGJ{Z}+^<nK#726(;_Ce^olN_4{9Y9)Ss7a4Uir>F4 z#s2-OqaFvB!1vc5j2mQm13k)Zu$Xs|4MMJh%AS>M(9Qwp^1e9pC$IIMu5|@|)KU^3 zGc9c<!hn3buI3D;Sq0oVm4b%~M<Y>}H5NOwnF}gOp2M+`bdcA7JV#iK<`!l?HO=>> zi$f-P4O;V(z1ih8H;3|;+_&nLtZ_;Z_uuATwmH0uGTnkbf0I4QGqhmOa045Gf#*+( z(JNxu1hw!~5VoWR4tpESX-xOxJvP$g*&sF+wMq#Ke@+k`twMK!92bz6Vzj-VKR-X; zRO6Coewj8Kk?eIM=@&8V+WYZT%xYz2#m`TL&=Trdo-x}AusBJB4NwT%N`Iz9G;1PL zVKTQ(eFb1Q%I2Y{qX1q3h(kev<Tt-T4nhSeH!XPU?mcC$#Z)jLgxWiluP`%LYxSBq z#2RJpgOjIS<2<769&)*|DNvK_wi+8)6H>oSb*D(jZDj#CC8W-3H3X+gAG_H>Qiax_ zb#ZBlO}#e92n`?a)Twjl+<dX=L=Kp(+)k3cFEWXn+mbyF&L6Iw%Tu$!xpk6bfeb)+ zIQysV6jkFcQumb@oBHE}zvm`*CW{TSD{M~-P#IZSAann4DXj9(Uw`tCoJtys2x6)K zXi4BaAdb-FNi>rDF=nX(RLzo{`$V6fBwLS{RrTYn6*Y}x_=>kspp|sDC0*QN;IWF) zbnQ)$)#d{_Y-cMl@<CUvvwdT{=qkhGqvUDt86e}l5*-@<)~@lbYc;60g5o-A#u>e@ zYRsymC3(G2(<WJ;<2f?k!*@vIN##Wr2{PGjBoR`#vi*0d16l=hSoC;p*H6eeO1x{h zdgH$q9J0i)C%ly4@H-qwIxP?Ixv4fR1r20%%)wN3$SeCd9KociV(>3zTvL_8h}623 z4W8L5URofl%!g~Xkm{u>_llYaK!|4FydACMQE1cfnGjc`=l5@N?u(K@|B{IfSdvG8 zIN*6tlryojJKlq8DW(ah6w4V?|3@T)w2YYhC*#YPF~_SG79;s24lA_dKV%|&YIhbj zOMsC?vq9PI=f5L#YJk%SipcNQi=2V~V*cj@*>GHO`d2`CF4y5+zHvyO=}T}nW}2}{ zta3PAKJFxa&?d`0^ZKwfEL+~)>!@9UlYbn%85-Jxc^XW*(}^Bf{C!UKB&2|GDEL3% z6H<<{1W~k>aQBKM@VX9@xi8N^(xvQL3~<6cR|nk<|DOF_GC$=%`qNax!%mr+QnX!| z>vFg;fpS_?fstE{mqpL-?4sG2Mur*c)Jx~W_$m-RKcH$y-6JzO9}!@f-zZ%8xwsBO zMocQqhjzb_*!=G9W<f#HejW-Q*$(<W<-ZthLQQjJ>KW(VC%R>a#f;(n`ICz0N!u53 z_gY6bm(}#yl*<fy7x0_&Ij?GLhD6{KuvO}cB+s4hZ;e1<-O;E|i+RZB_rIwc*VgJ@ zG;&XS>@CJ$9!|TLGKPLF*K7J_Q?DHh<eh)Dhc9rE*)@ae*_k4QBKO)YyODYWveqYj zt2yb)*UhJE$NVX3M@rkmf&I>haL*JvCi7)Y4%Mh4f!81-D_J*-un^vFi2P{!Co&iJ zzhWl%JhNDO$BN@8{v=)wV#M?*g~uz;6Q=Q!roR#r-?ye}R7U`jn$w#8Yq3eUiH$^F zm+GaYnsPEO>(Ni=c-$S0-P}{74Ko4a6ho)+AWA=px51h=P&F!^42OCY=O>Sk78|6c zr^AxkLHzc=H22j}QN3-ug9svu`2&;`1w_)KB^8lwP+|~i5Rjo86~&@UX{13ydPGX3 z1w^_9W+(}17-GJAqwjm(wa!21to5Cke^_f`v-k7t{XF-5UDthIme<exI&t3#0%C}$ zsIHwff}@KQepdik!ENwq4gz<rVm&l^#I07=(rOQOqd!;xum114t*L~V&`{5%xAxSB zKK@`44!~U1dJ7;IGUg25pv5~lEdbiCsQ895i{<9?xxvyrJ)HzwgTW%(`OzZF&dF*q z&{sfgZ2#<8PZ)WuV=YyfX@KY3sRR$RKH({et*%Zet*^p`cI?GLAhqbWGS$++Bw_m@ zD-}~XNfsA-+wIMpSL#kz!G?&OA|E%@MjS5$5C_&LL&v35=kki^IEcGE+OQXBcs4zM zR+Dt1J#FX8US_Soe}7(!R0K>fH8=l#!cgTW{m=clzKw%hqCai9BJ<up>c@jFuwg@Z zA!3Gai~h;lZ1=CGi%jKv&tq(>jODF#o4<DwX9$8`gcfw33+}}j>}Ofz0(N_D8}Y*@ z>nZ$OmI&5%jnVO9H~E{(Y3|E(i&fKIUg_@Dd=U{I{&{21AArVP-<gO>fQ!TzcaDf} zJFYvlS7lgzfXRRoK;`snEG$N=GN!fF=5vGTbYEeE%ld+}4nS_}`!r|ITB?%!5A$ZK z)^()ORX>q2ly~R1_?`GJPm!;e?Gdi=B!sf=5MxYwT`@e8&-!S~YUz}gfai7!9h1*4 zVXbu3-u!3uHUDE;EaxCIPmWa-PR&wQo^yF1E$wNPr1h-t>2a~~CD)>|9Cq)K0{mNJ zl}0`eRetA<E+W12`n|{2;xXG;%8Jd+vdau8g?~X2wx@L8@zD6+;&b+hi#gUEc~DC% z@J0b#Qo&C3?grD3lKB+5(_D;#vuQE5FJ1;P3Oc`8*DOeakmYWLl@cV~&GY^5@2wcs zwuy}Yu5HO-zG3qF#mU-YJf!7u9&N$~pB%N}guiEhrcp47bC7&pcNM%du?|(LEUI_! zZo<xLZf14~N(baO`YXK}R3vN~TU&C<GP71eRriKR&pY$XrwX7HuMq1)fJWSdDC$>_ zSGu&__r%8mp|t`cH#?&*o<hy$r4*%Y7{O3Kdc?uWS)hA_%}q{8Nh>28x^bm<h6D$n z0oY`A7623IMN~}b#6Mq0+a~7nma^jQG`YNoHAkbyWn`vMiO>V>%I&9W)_$p~7csM} z6<0ZKius=s1+$CeU@e-YjO42qRiCl_D|KtLxL#jhcgc|N+B1<$zt{3#zYfYXC-R3i zIfCurrLHEP(jo5u_<ju^RK!|b$LhEJ_@VwxH#BV0xg2+}ehX9WF0wIX)WGQSL7Ior z;akUECTH!5zqij%^UG((Cnl%^@@H>}S{^=pnDdS;7Vkq@>^Nvsnsxc#SQMN(yM|xG z1TQbIcE%N91I&-}=f8Cjl|SkDTwE73s;(xX<Y!M;P>!ZF{ud}00;WwtGTPaI5M+=D ztM%L$tokvu=A-eC)x%lhaORi4;nF#MkksCmZE*b@9~;XM)R3&oBP@e?|GAm#qLOkP zWfmkj1`e%Sl>Fx|LXXT`&d+C|Jo$j{BPcybI*d>lpK2Cja4zf*jf_lFC3|@UEI|x- z&8L;a51JS^sN5}Z&6MsYKgw|V*Sugr{d3#L|LhAT!C<Oy|DmUFP(KM)*Q>5WYHHBT zs);h9skOCu(1u;(7BmN`xv!d}rh3<Py?+W|mjucP%hA33t(8hxgmtKt?MP@0{&aL2 zlNm>V*W?<D!U)|Q?PrvfM~<*ngle!}<aC^gxTAQ-)oJq0?ZXzUeB$B@t%B$&oW);w z$LQku^XHp-*RwJjb*Jf57`!pk082w=9~=4rQuY_HC@(#mN;qPv*z-@*VYjVROGc3* zo!@^KEWhN&#>TH-S?>ksvkMAFkvNCvIi$u?3bPc%2EVoVcvthO&=!uxWym{Q8YK** zK9%Mst?_*l3eh+FIfX>|a6B{{T8>sc35g%o_n&u4hiqMdT^O9dO7{l3m`Ei!|MsQf z)pZ0@72O;e&d=b@+ZrpJ>{E4GEBV0*m;VIAS<9AOhfWQ#rl(xLTX@01UWnlo&h0Z( zatEsv`qnK!tD{xKy#}@@{Z9_PywtfOjuu3Mbsm_#N%0H(-J>OCSwG0Bs0haJoEpVV z;-EXI>kTGq0bEP7P9z_%p`r4V*jM%PdEs_9K9C)$6w36GppGQO^|_4DCj4Tl_`&io zXcnZ~un2c_TtVY`E?sJC{>IHo`8x37GC%%5jzrm=0gdj>THQUqy;Xqr1k24WqaJoN z4#%{gfs4zRWBW6!N?ksTzI%5XBW&7l^6Cyu`>P*1vRU^p@$eMu<^Ts2Q7m<>MbKtb zDlcmT%ov6o7QFy!juGi&bkbZDT`1fAT6vK}>_6b~3^e3hexgi4dRq@Za3nl6e#q@# zYqZkwo-4WIABf+<>cz2XV##xP#ym)juvsI**%KnmWDjxjQMwy<W{_q7FU!LatQq-Q zMlGaayWRC}=$eIG<cKb3x8JZW9}iE2EKkJg+fT0u@bV&M@|QWh!oJVP-7AcDTYon9 zf!is6`g_tY#0ZWH*xGP3EZLG5_K7%2D!GV$v%;<S3B+6$Ho_G>ZuYPyj(6H9F;=PN z$HUtzt~ZqZYnf|b0vrkAce(LZW`l^8y3cN}X}qx}GX>DYr~5kOTU1Qfe}5}%JL686 zkspOSJuzf|TSzR~k&CRg-N;JQ2@>FV%67Hj<)k+L4(A5+lJLAYnT??I{ABZk%+9ly zE&@83*}&-W#w0H55+WJ9y}uawiiF7*eZ9*Y)iSZvSTH4Fn@W00^XwbqmCbl?mbv5D z-Q|!i4{Wy`Bmq;oaj^9bXssJApL3?6(bY1B3AF1>vl;Z+%bQc%>x3Aptn6-g7a}nH zGXN;x+5asX*EiomRk><E+NpV8D-SRY@F^g)gje@>qtr{%B$9ulAzv%xzf4@%|FW;u zbpDT9z`GbVnBdZnBVW%-fu9!(A@5w>FSn4ScD!o|@%@QF3%~sXM*J{$ddbP)`{>q% zeQ|t7xOg^#vVTF^Qo&0T@vB<>a$j|lBFzZ-h41bi8M%a&lGE_SYnWa8^Q}4AQfg$2 z8EbhUu+K^O2E}LJK@OvH%de@?(GNM)xx*nH99pcYI$c93zv7HLucZ>iJ|ob1V$z-B zJDjre2*2%De#eWbNDA*tX3SLS-tr|oH*}VYoV*qpjuR#4dCZa*MAI%GrbTh&q?gvt zXu{j56Uy9B$C5Idl(%yfou@;B3SV};Ay9r3Ik@DR%bV`U1R`GMz|nt7EQf(2=f=)u z<Zwb6XQ`gUTE~7pnHsJmd<uhQP>w=A@qHT7&tMd2@!;ux$ps#?+AVl(_yF}NKX^#~ z2Uqt&<o=Gp(f%-~hpMK3kdLk_u_t$!gjT&^?=sJ4fYHb}*_6-P5IXv;fVlFfZ<GW% zbMiU|R_pZHv<mk>dm6~>il4IhAy<sRF^B|o|Mh>aF~@%kJN|bQN1^^#yO0!9kfDT5 z<OOwXs!S)u#OMLBvgF!4?+iApgs4)x(KhXpf{2`lVsA^D+bg@R0#>h_IVwNOf$eMF z|3E1lCxYnmGC~LEd4Z9GwX**qgcfpgas%!`K?hKNr!O5@URg1fnnkI>t_uq_L>FOl zGl$t|QPp{J;rjgM!J^kFQTY;EHsPc0Si>=5NVt_GG<1UBavk=C4H8`240xkTZjUE@ z{dz0)c(^{w7vqS=bR#IFrnEP`%fis5AWV$3H=c*)^#N41to$KtI**!~u5NTy5IQ*- zcL1d+lfb59gWMZ<d3kxrRAOw8=%FN^hKLS=gBRcgCr<_lOCExAN__69j_IZ>JaM2} z0+)6R47`YzVGGR1P!@MU26xQ=!i5X9wx!wRx0=XM88P*o*x9FWYc&5uO$`l+K|sd7 zdgK`D`%6F>p%|!iu)=P%61em3a~xO{%BhEgO-HN)5VYo56f`d@2nL7rstHPz?*JD! z_qUc74J!3$RSXFPqAUuz1na9_j3no7;p&Tfh+v#agK%mnUlFiohO#3dqLLOGWZ^6* zUXgQQpFj<iWLZmlJG3f-X+X+@)_~*VN|nnv^feVm;GjZnxW1GWEJGZ>UPuv-pd?RA zBR>}0zuhH&_s=`~*Pdi%&O@OrC}DKrEG6HDZjQiyf<j4GPb`_3n4Afy2Xk~c%^FGM zmvO4$`ZcKy?FUg*=OiS2z!MweFGYen@enfuR0B`M<1b&NsacWP`hHlZiCQ07c0vZ{ zOLTL1qq61RXj7mpUV*M5!tOcdlA~sY8eF$DPZ^uecbFy$S;)iCd4jyW$_W9+B&b6q zVNJ;8)CK$_k~M;iWfIL$DAzOx5#^U08hXkNWXP(}U*nDT_k(zG0c0LY4t~T@is+sr zr4Yp^)R+~XJxX5K{{e!?vw%@HviNuT;GE=nQIfS3+D+65yZj|^NaaOfpxOD0C`nQZ zdHb7ikrtOf@z@^4NbI-Bf#=^{282kkWcn3_+)`u@JAg{O^xeFv?%V>rDVYHoJOFH_ z=H{^0MjsfC+`r_&ZmU*sLV}Zv7YMy0mhSK#X5;$X#Blv2=(uri?ha#OVxlCBo4Zsd zIWqZlZ){1q>k;Y54JLSi5=puXr<0K1TxO&ZEBcddLvNvu{rVsZ<e4BtNp>c+hrZrl zqbNW&#rpT17J!4O;Sr2!We<X<5AL2c-%wb{{sd1b)HL1GGEz|WO8Pqplk$fzCZnc( zyLD5(kmA{AZzizOBD)9ykHPlj8u*Il!fx>g3rLrqd~?m8iVkCypF3$I5W}Ff7pt63 zS#spI!42rWXERv3-K*~~zm}e0zpg?rK)xV=Z!0&!XLVa^@(42odMtP(+EtNkk&#hS zWF258yB?mV<*X?$P8^e6j~1Gtq`t8%Iy&X&ZFAhFYW$a-W`$eD2w#=H=UB#Tm-&I5 z*;km;pHks+rG`lS;@-SD>h=&ipdWaWUR1QY&D_)S<zoW_;|F^y+{g^z`FwYwAik-0 z1d@^S=l3oZ1EU-W;3f9?A!uOi@vF5rYv7a7L>fG!r5A++E%Btp%?Xu)pG&R>dN)L7 zg<^7w2)&iNlfN36JQTH{tNE5`Q)ue#<fRdhG=dIT<N~VEiqFk0*;)Pt{%j5o4q80- zxFdN?z1^1Tq84H?<p);%LVw+t=~s*B1qC+rGy~D5PR)g?m8tk^a~%!wu4~=Bhhn=7 zyMqqi1vT<oc3xyS+F{r{p$%4A>Wz@l?8L-;AL3~!E8r5Nz_Y7z?cb}zwmOpeO-}3m zwQXU~s3g36rdj+m?!&?WBy3ynsOR>a10NVW4Figu(@Fu-$jNT&o}S<5c=QCk16)Qc zz0~DEe!Uc78)0pTb+dL6$`byv8f6&L;Ls+#9b96g)p%pIdRHNe*T<63qGRCZCNKZ$ zfaB9Ymy)@&v!fyr!)0i)Q}!_{lJ#&_+OMIM7WT%VozGiGoEHY5E&NKBb69EQw5*nb z^V_B7)yGejLo{~Bi)MP!4qA0yxCF@(WuKX6YP9G?tEZUL`+Xr$1gDzDlb~(=<T!LN z)9I*~LtjIoM7Vq%SQzRq6gW+vHD4Sk_E<1yW(J@dBYXL*l05TddyL);;Uukok?T^Z z75XhWL)Q2gnvM-*=cy-keKhvx;;mAgWl_gdd^V**DJgVvidJqfY-1CAPL@puN$eH% zKLpSzX8p&i6<TO+u8>neDs=RBdD=+wF<c{)tXARL-u41^VIaHvF)JH;<;wc6^jP<L zuMG(y*YDK}!{ubQ5qbH}!!8Bis|+QyE{4ZQ3L{JEv@Y;swBLw3U({$@0#=KE^#G8O z4=tC;7V?eLhwhB<fQbO1cj*+C6CBva0oK<3WC`?l>XQ_@ufOa*o0bj+NmRB2&z?GK zG`K!5IP}P2ZTs%)=I4%XTM_T**O||!#AfGy8ZXjNQ9~u_C}qs`6<MyV@F`}OtyFrw zi^$y&{a{d@MfeW&M*`N7xmJAFt||OTkQZ!vw^Nn5abpc<EX&|s=99`T0Zfk|aj~Q& zugrZWREFktU6tGqu1lW>N+mu3Oq*q-W;=dg1_E8*ktVSbl}XPs_qAx2o8&q}pB$U6 zx-ndJRaH6$y5hIAm)o8ROxLf^i!PrY-{s<oYy=9U`AfZ0Oyv&7KDA+{KiS`B3+VpM zW*4qL3G(>~l^dp4-$81ItN-9r8b*FJcU9HLZZIv2y&-nXs$Yn^SKli&D*=EyJXd9I zQ#@wyGeNBVESMczaf51~gi3kJR95(uOaK=5K<k=a)iv<ssmwF;KRnUUW22dNh}r$t zt9*GuOM|P-?$aGE<`u<6kAQ7p+6v^*^nH|;&+-^DuQR6%`R&aV;t+T}@$d^Ma-)0E zf+yfQ+na3&KWl@+?WzNV%-S<&@#aUx!~_JiX+}I2pAIgT6%O@~koq4K7h8a0JAI*c z;_Bn=MX~j_$-64%?OP`G-*iYf0CZXdYw=q@qc6sL>M|V{oy)I2Vn0u+sp%z4jopn8 z`IrY=hI#xI&G^c-^$*(%KB|M^QBfCzqR=8E438T0=wsGrt#ixA>WP-t0ydj0A|kpp zB1Lvpo-;bsqYr>L4iei-89G}zv971pS_IWbB>ZUsk_WHf5Q}2OL#a-_LPOW}YF#Ww zYmG#k1Hoiyxb<0O>IrKSOir41ME9pPzvntt)|oGFVy6rlu8i+o2ojRsx?0&^C=vPW zG1La!_y^Cvs<OWuKuz)I*(;(X2KME{7*UsTzoosALjo~gKkyMj=0HCzHX3UA50s4^ znimmbV-tQ990=H32o!6HC@|ipfyk@WW>DLfGd0&hWMjr5mN@f?(&;(K4#q<KTX+K* zk2Qd4x>lvT0u(bpcp&}L_Q<49E~iH8T_%a0d~};H_v&o_O?s7HGp^RQwqrx-iTU~C z5FF;n-zzqS?t?syc-K6%7}vti?s17mW1v1bhbgB?*l4YCGs78`lmY1GrE?nl;4)fH ztnrt4w_@}&4LEc0rOo7dP*<nwWIf!gF@?63ipt7N?gUHf<1*O8T~tV#clv{6)?-zV z<xJ?NO6YentJoO%P+DZf{cZ9^3%fGKkom~YmdA$Ks^~{M_%HaG(A1W_z3s(`pvBWd z(*7@kUU$;)o)P{cvG+nhDJ`WqxFKScyLa+l6#YPYtn=+uhsQcMb9;*w5Sit?wC4}g z(-5L+?cU9iIepRKp6%7lCGZReqRs{o^7df)u0xCMA|Poc9!ut90={~*&C`@HL+m*V z#o<{#$>Zav+};EQY51bmFiDfq^8F{R_G>2w6pGzn#<Fb@`7a$i;9o%L*f3w}echw2 z06PEYpfzk?Rofdk92EDOUlYXHugcJB>(Z&)9&kcZTwi5GY6Xglo4FC~Lcd~5M?H7D zG*lw92QM=De66b8Av<E|HSdId;t4xy(M&eJC%fys_qNS1hxQvFHc6ii;@h~NN`9WN z*XrEtsF9MiCfSUGG=Acs0%P)zHDWow#D@C$*5z%B7F}k_%!gm=c6*z?Oj{p2L0vK0 zJPX*l!Hb4EdzL^|#8lFe_0~<x_ofYY;8x)=gO6U+?@nuB53I82B0E*n*z0CV;K~wU zt-)@k(c|*67zc_hdE`2Q$b!><s>L6qUP-b{>vgYZ#dfGqK}$^4_WjAfPrqb(O7oS8 zh$yIN-27e}gvQJWVWXHJI~rPwzr2LWlmW8of-cpq#@6+wUW1iyh3yG~p17zZQ#ukW zA<mYevEg%u6Fr<PHI=VNa`be{ThZM;&i+iCK_9nA7oiDdKeOj%NQVUj+*>P?e5y&| zQ98~{WhZ2(EpC20aX-oEdPl)TdV)Y>LRP5-^F52+h3`7%nxQX-og3mH+&?bn>a((& zZu$>Mam0Quv5mNcoEOYf%3<FNQ(;n{5#Jcu@bz=w-HziRQf4W02zE@b-oqf#wCLHh zXP5il%N!x=s)xNn4;*i^ay-`OI%q113;v9v&f3L#Q_6ACf_7<E6FqviC2w0dhBkcl zPaJ2uaVMK)XKTuk7VY@5)vSECL=WU`Qa|@J76IE^7%nvT@!WfHM11Gx@Xh1GDOne$ zws|GP!ieS9I|z&i*{VZtnwpxHTBr#;YX4F9+G<S~TkNqyY_5OiHP_+)hTJ%V?<*vk zxt<!nKI{EMvl}4y$sHjyo>NrC@Ee3P^0TrG)X@fHjkOhyco6-WdvvN)FqmZ^Xtnfx zMt6^!aXEpc2BggB?)Vh%u)Om18h<7%Vc=J)qczlAVHpv5pLyHGW*2!a8&%r_hY;Xm zu5aF%tZM{R6}i}T0_lPPki6Xwy_TMt^D%jaWTn#EzGa}N6@^%!IJ;U!d}|IYltVmz z(gu7|<z#`V6$CBN_EofAa5QYlD^7f#40|kc7z&KXoqEw;odLZ%BJp(+E*&rkuXCM9 zmo_Lf3;P|!y+L8Y7b7(0H?h4rMUnRsUEv<xYPMCRu2$u`dQSKYxREDGKa5$F-my!t za=y07k$C0r7$8jD;nVfqkEV)$Rf^vCW!u}FX#eu%3m{qyk5toj-+2!6gB-oUcGxAE z_mj!k6TSl;BTa`tul0zUNG;bG2Z^OkHlz$4v_kr1__E)J<s~9-;L!?Csb#5iLlE<J z--Y8%UzkC_+%bLS=JTWRD;?VdZR)RGce~HZGCOq*!@ck|Or>gL@6hofmqoX!IE{t3 z<<3#VTPjKlYkBtN`pXts)@-a?Tnc*Mt*k+h4*dRQLBY~V+0FT=R$Z>Avfu#*R14=S z)cQemU}DrC$_LJET*FWmzcw#eE%BTJb)8!2{TxNyAtukw&UP?wZoH|Z@BNN49)O^G zc2!qg8!cme_S}Bpb=usglx!*`SXjE+^-AufIJFPEbmLSZ?#cns4W)VkbeqdtLn;v0 zo43bv<$k_RgN|Fm6lRIKJ1#i-*4Ssetnr6U@!fqJe1Zr9qXY=cW?d@RqqCtY@D5Mu zFiUCXl;@6IoqQvK*(bb#NtS8GaAH+U8v5mB3G4wa38CXs-RCW2IVmyg>o*^E9wZ)v z3Nm0JL1f-CPaN}SZi*vbiFy+l$m^q^PhWQHU|`>PR9xI{9qr1liUY<0XW#LjkGS`% zO0-(nCpNVu(%gLanzgGsMxB33K8qO>Hw=p=m_TV22SGmOIq{PdmBH2=Sbx519%lOd zk*DOxRBIyR<B?4!)|v3`2n}!O=S2uq)l3~kd0l@e71S&*#$x<?PUmyN$PE7(8H|5n zG|L^hdsJ-3-WmtxnoS3P0vhBjruq9}%-~Lke;n`BM3C5fs-L=4*irF~6*quqOEz1` z3a31Wata<>_U7?r`&op2ur7kF`Ne>Ny*Q!oG_t61(zo-MWMs@0c+_+8S#~}i6<brr zYHfpdu-JB3cUE~|uSMrW@R22Axr4lozhnPL<0&e^-Mpb@dGj2~IY}t~Dz)PMDzW|H zAzfyrmx`*E1GZUNL8~we;t#D0WPYU0fpi1#rId&Kqg%e)+<KMLp@|S&YK!EJclRo< z%St@HZRj{gc0o3SE;!$E@=X*Uo!I0SZ5mK2GjEfFKJC29Hr;JJS)2E1d3n}Q0eg72 z)4b_VOj`l8EC9&cEA0{>=iBW#wAd64N{RJnwCPR74}&fQ{a&10as}_*n7DH17Nr3w z#}(Nk(5nblwm;cQXsL6oGai^<Pw*LeaGlO$V|K=H1`|0flQ+-P`IuCNlZS^<7~@)X zdYh>yNdcRqE+#tmHH`ci!|#~}=1hX+$mcDpUc+>&%zK5iyc>9?vK#U0b&BeA{>gLF zH+W0ZR$i0hFB8wvJ!@+T#8et!<@YpyTkr&tQ!(6~=_$7E)%sMqc(@^(PIR|OW3hZ= z0rD1y&eqbD^mI9NPq|ATQ0oL=zW)*7o^Vd#){zWbUKcIIExlKxCG;vL>@`Oj@>bse z`T_$EHMv+`i7H9k*k-oPj}1(P?yUphI>hYu^(1u=79@pOb6&yVsc6Zbe_;W}q`7?a zfd!A>Lnm5Io5n+(;(B%H;|~JzLcb?@Z8gPf=b^*?x%@Js$z*nP@s?H&FuG)coa4^r zIWjVG+oLQ)h30uJ^CBrWdRpK=PAqf!wFnh~A@X<l%lqd9phGUz<e6&8992xxF-Np> zcxS4@RHw37>T^*(!gBVg_n>a;F?C6cd8~5A`Cxaj>H}S%hP_wes-qS?D4o^-e)#ro zrD>$%tiAn5Q1TVBPd$cg6R~JCN{^?#D@m$Xx*&>Ggh07AP@*!Z#0+vRc$(x@RG0<k zK2(ccIDft#EixG*%bc*XbP>X^keFOr!>jA~D047jETldz>e1Ga^(Df~(z2i0+)=TJ zG9IR-*?4>1KSEE!_KWw;*=8qVE~8KOfGgx}$HjIUd5Gm(07+O`;}&+GCG*;Lrmg9` zrrGpBYs$>t?;U+q$gS_aWvWh<Vb&fBp&SBjNnS|8N!##a3zBu(;~}Ycp>i21<trqh ztN~R4$zH)ZLla0IIrn^2(*qer9WC-^fdX3O(Opd~(bv@U+zk*E+xXTA5--&?N$g?G zI_Prb8(6WnF4`=}>F?9ZmG=!gCmHiqq>N9WQa>DGEkB>TefFkG)UTBwGar3>aRAvu zH%5u7H1%(=LXRq1C$s$y7H55iymxtXi*^Nf?NDx%)sJo+gE3-^<GuN`7Xu$1rbv&A za~D6Wj;RM~M-k%U6zyx?<E+?r{oRvD7!5a8sa3bSvhJD(@ThKzDhe8saky=cL=yhB zg5>ykpD`IXMa4V8qgydVml(>&K*Gxraw`_xFTEtJ3NOTG5-(oqhbk%IBs=19iD<BN z!kl(b{ELI_h8}5E5s%|KLW>qs*DLgD2<%1sXjsurGz8?7RSm5%LJ$U7#U7L%@t-*w z6^Q+6TUxc%Gl+MNSy@Vlf)fPrX=jop&*kIbP?cg_yZ@^PR{Y)L_wE*&&BxePoGzOQ z9JKS8uG!x8aZ#zz=&o-imTP6HLm?RJqvzb25fy+M0kW(R$z5SA@1*h8Zf|P}NdlES zR-@22eu_;&5#n;+XELucRar7|N(&C9in)yRdyAD9rPLJ_zrDi028|<|ilMh@8f6xg zfiRA4q`0lnHeRX#jq*y>=G=37cApAQxx9F><s+25RpYZ)q5|7NW?U?bJsbzHBrI>| z=1d@vxbzlo-MYHEi))KhRfg`cf8)`mU6LTd*P2nd{YJ=eS9G=M8SR_vbOBJqBKEr^ zt6}2ZmE<{fZwM8dE7nVMOvSD?4_}Qdy`Q8{*e2pab-Vj=V5bb~RjJgr{XN3Jwg3R# zhbLn>rRH+@TMJGSOPd$)0!?vapbEUi{|e-Q;qnTpd{LijUW6n!f09<L)NacWIbl;h z;I3?ZybFMLtJKUW#<h<g8}XuPI|<7H%H0s&Y?xK>;J?_Est8XFjh$-Gbl0oeYgmGu z=(;=fn^-sUXI#gBaVy<fWY3W>0L5t~0aK$Sh~_h2T;ZEs6HX$AsfcNOWYJsW5PWO} zRY$)07X+!Pzu&U(lH?_n9&h}S+EV#e&~U+X#D1Gcs{#iFe2vtzAi#2jpn~6S<Y8m5 z6sDCkPsF^hf8dyk#L5t08G1z)Xxq@a3Xf}(Z9L`Ub3dG_&W50Uc9pcQ4JgifCwMOR zFH;*f)Yp$Z+I4~wu94s{mGb*t(;xwqUqEHM2ZufCY&A9XnKf|H7Gc|Y>7`QUF-ugc z*dBp$2aIQeIR3Wq7Vyi@<3w*<yS9>%?A~ItLRuN8saDi^)#Ud%vg@bhdAsBR$GQ$( zB|PhT4SOF=?d)*UKUb_AOZy^iU1-*tTfR=`#>wmou^_ujrCM{c_eCl??>#rTuL~;m z7eA9?gcuS$3H@Bx(7R*)YXi{`ta#(b4jFGGShPPBQ0R9AQr6II%?UQfa^houCZF+3 zTmEIUuUgnfvRv;rD<vXL#-V*LR)?Fm8N&j>vJd%?lA>a`Li^H019-1A*A>;=54Of9 z2s{i{TC%qv5HBD$-6U7YF$T;+z4eTkRhOa9?tH@V4mc#cZ-z9O7!itSxoKYOqw~;( z+UWC(Y-==8Eo`*A$l~#{A$V~4u>(tv!N%}1p6h^1@ZhleuNSXhhqc(^W4!eOVUfFm zCpL>*(09V)o5T~muX+q^wxC^?*ygpSsF>eHhlWlY3&@5?4%`AIZTgVf@O?ILszrdt ztytQi%IVqc+zcy0>`+u5pWqH(`HnnRY=KCSu<OTwnhZCfoBKr~epf0rtg=O1)OoI` zQQ)=ALXnEk7Io%*5D5FEIvq!Sk4aN^4!<BHdHcCice|clyKaF=re_Upf{y_2LW0E% zpsJ}T_lee0@Ls$`oYwSGp1vq-@S27vIyD_irf<p0ynaCu=J_TxRQ<|*Nn#6oWHJ9> zDW3zV6PsF!c^@yY$sUy;zT#q!_$7OGdUImiv(z~i^oH#f`LJt-;+sY<r60v^)zcSU zS-T|o_!FWVIY$BIf_CL=KFvBRs#5Qj)>&Pub_HF75UF(~+U#Svfw=cdYiX@)U?~0M zz%-WhP%O@8On53bE+TIDvs-`PX((rL0_6bC3O(r9+vS1dbY6DqJ<1>?8R2wz3GKAz zv`5R%&iXC(iWs!HYI&+g`{oA7Q1;T>t1jp0vwfYnH}q;XA!=l9H4f&WD{kAiAi+fG z2&S;4a`dH_PM-b}C?0b+2{C}eLdOe*x;$<{f30HMDz8`i<1I<?ZiJVR4$%d4qFooh z?va9XCTlTwP8IY3edpRwjiDX^VZDSaaTp|N@`l@RGX*jVUU{Sh=&ND8xDttE{xgSu zwKm8YD}E1M%QDN2^Q?^(PSDgRZj27%mKMqv!izG7(g68rOklN#D7CNX-uWm)RtK;` zwbQceC|x`}AnmNYuAw6F%ekEGceIev&Zd1-T6%gmX~<>S1IZn<0nvg!k3Z`_h|8<g z1%#sn0ZoiEY$oJT7PBRGqr5@>+C5L`zEfxNcoJrYV0&H#QU5s7VJpczd~NPU(cQKi zD{r`X6L8F$T9ht6Imxkr2}=C_SqtSEMMU;lg-*i|YYi;M1`vU3?PutOC>NWxvQw{K z1_T;{V1iEM@Sq`a0<<G1U01fC_Q_F%5wqy@J&^<x!7UpW!R)hRS!!#5&-<ZrzSJBH zqajVf(-d@$1%G<1&(RWRf}LV08#H&2@FC2YA38?o+TK-gMG42b^RXeJZ0UIu-Z?v5 z(+*TT%&zOW^V?17@ouwC4LD^Ft-MhBMfLvRthk|}z*n#4`V0Lvc{nSkj&=nLglKp@ zFH6HoxAG?y+yvRk76Nm_3b6e&hSYs-=^`!PJ<i7i_sru@mZ4vTp1o#RzWhp#|3+ZA z6fm;T^~!nd(H$ck!KDu%JrDT@sXQt^ZqIK8OvQO9$F)3x0A2g)2l`wh-&*pJUt4z- zj*<+C>HMugw_u6TW#B0z+1D}fO;$z*Bo@&Jfa^mcYZmfKwEoj4!Y^1{_7n*!k(0oU zeFJ5$D9OfuHi7Sqrtpf?2deFaGxSWveFX0HKdCdL2HR4E;X8&La$1_=+}xRfomC+2 zG{s&sA(SF%r%}hvd+`2SMRqrnW7_b}_gC~e)0x+hPoEx^2g1>j<=_ctTxlOTRV)^O z^bGjH&~Sc;7qYw&dXtJBsA3LC+bBfUse+kJgBorI9fjinJgv62&rIG~<HJhVNNSj% z#-78e|GnrZCnWoljsb^zIaDmu9ihJeQjPbJiYve7wm4BxU#G(t&O7SuV?!DsuK`;R zAbIQIvPLb+IM4i>UoC?H^Gk+K9nd0SYHDi#OIF2M=ct}3Amk`YiW~bR5XM=k;4b$v zQcOB{Yy>#Qws~h-TG|$AN5`TpLFAG%$V!2Wh4~q4G`R|rp(bhI)nRcuSakmOEBgPL z{nY>k!E!mb%NcNbfUosEFg6C>J+(|tYM-;tOpMa6G5u4i9}3MvVva)Rv;i?26v{CW z=*<%)$Z7LKOQ-<O11QO`eYRQ!A!SgK-z`=ILDz;%-;*?Tj0^|LSDh)S0gtg)p+~7Q z1M3VvQ{+!s!11l?!cX;oaq;d5Ie=et2a=ns+cza8<qzvRPfbHJ3Hyz-uC8vzFBHOc z6RaCUv-glHned@U!P!1;-`d-epbYv1gQZtV7fA{SNJ=AO`Z&KL!@Sm2&8D+^1qz~f zKvtl@SUvAdA5c%jqq}d=A=wHV>h>8&0VslE*<M8`^4Sk+^0@>=BSkFJ=gmAU#1JT8 zGca96#e+(;m&4%rxIc@ZV$FOm0~3@XEEcZsgh)d0O;q!ZjdyJN@fTF6$e)5p!lllx zupfjOYp3av!)7JbhGri-0}ZsBS9(cqNl8gT-61#N@KF5Q%r0VqlI#o3CW2`5rGHU+ z=m|Ncb3jD!3m}u53O7<W38KYlc<`}tv3zHtC8%;|wIv$5o>*0U6=Q0Ln~u~)fG)sh zu%tV(|BrCwP=oeXD+J#*{WFg8_UCknz)!1-Ogq%u%k%ClPEv7Jx@<aBh-SlS+zWJ| z!?NDHG0kWEq5CYw+>497yJm&=rYcro6wiI*$A)G<M>@UkzdrSt+>Y6=1VBl;A~T1) z{{M92`F|t&`ETsF`ch+l7Dm?=@f3u=|2LYE&J#i1LL_odJdlI`NRIqxQj~(XyNru> Sw>7+jl9N%sl`DP!$$tSSuJy+N literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 04b27adb51..8a3f518eca 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/user/guide/providers.md -providers.md: 2ae1093699d8eb26171a2403db155113d84e437e -providers.zh.md: 6ce513c659140ed18716bd5c8f75c428ad981f2b +providers.md: c555e62d5343ccc758c0c6e699d30ffec229f2df +providers.zh.md: 6c4154c86db3d95c6b083519533954fc4cc90e45 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 2ae1093699..c555e62d53 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -17,12 +17,16 @@ Adding a provider therefore rarely means editing `cordis.yml` — writing settin Start `pnpm run dsh web` and open **Settings → Models**. +![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png) + **Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready. **Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. **Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. +![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) + **Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 6ce513c659..6c4154c86d 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -17,12 +17,16 @@ Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器 启动 `pnpm run dsh web`,打开**设置 → 模型**。 +![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png) + **填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。 **添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 **添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 +![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) + **让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 From 2426be893a7200bcd3d508ab30704d370a8c61c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:21:17 +0800 Subject: [PATCH 142/190] fix(scripts): let the documentation site carry its own images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image the manifest does not publish was rewritten to a raw.githubusercontent URL, which serves a private repository to nobody: raw answers 404 unauthenticated, and a github.com session does not authenticate it. Every image on the site was broken for every reader, and no gate could say so — link checking asks whether the file exists in the repository, not whether a site reader can fetch it. The projector now copies a referenced image into the generated tree beside the page and points the reference at it, so Vite bundles it like any other site asset and repository visibility cannot reach the published page. The copy lands beside the page so one relative URL is right from both locale trees and a page's assets leave with the page; two sources projecting onto one path throw rather than letting the last copy win. Canonical Markdown keeps writing ordinary repository-relative paths, so the same file still renders when read in the repository. The guide this was found through is renamed to its subject: 配置模型. --- ...8-06-doc-site-carries-its-images.i18n.yaml | 6 +++ .../2026-08-06-doc-site-carries-its-images.md | 39 ++++++++++++++++++ ...26-08-06-doc-site-carries-its-images.zh.md | 39 ++++++++++++++++++ docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- scripts/project-doc-site.spec.ts | 35 +++++++++++++++- scripts/project-doc-site.ts | 41 ++++++++++++++++--- website/docs.ts | 2 +- 12 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml new file mode 100644 index 0000000000..75018c8374 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.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/process/2026-08-06-doc-site-carries-its-images.md +2026-08-06-doc-site-carries-its-images.md: 21593c2cadb6b2aaf52350ab61156ad892bc4163 +2026-08-06-doc-site-carries-its-images.zh.md: 54aee878a9d0f16d1fe3b219da7b248fb5148fa3 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md new file mode 100644 index 0000000000..21593c2cad --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -0,0 +1,39 @@ +# Agent Note: The documentation site carries its own images + +Status: implemented + +English | [中文](2026-08-06-doc-site-carries-its-images.zh.md) + +## Problem + +`scripts/project-doc-site.ts` rewrote every repository-relative target that the publication manifest does not publish into a GitHub URL, and for an image that meant `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`. Nothing in the site build copies files: `srcDir` is the disposable `.generated` tree, VitePress sets no `publicDir` (its default, `<srcDir>/public`, is inside the tree the projector deletes on every run), and only Markdown is written there. + +That works only for a public repository. This one is private, and `raw.githubusercontent.com` answers 404 to an unauthenticated request — a browser session on github.com does not authenticate it either, since GitHub's own UI serves private blobs through separately signed URLs. Every image on the site was therefore broken for every reader, and no gate said so: `verify-md-links` and the projector check that the target file *exists in the repository*, which is a different question from whether a site reader can fetch it. + +## Decision + +`rewriteMarkdown` takes an optional `placeImage(absPath): string`. When a page references an image the manifest does not publish as a page, the projector copies that file into the generated tree beside the page and rewrites the reference to `./<basename>`; Vite then bundles it like any other site asset. Nothing about repository visibility can reach the published page. + +The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. Two sources that would project onto one path throw, in the same spirit as the existing duplicate-route check, rather than letting whichever copied last win. + +`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. + +Canonical Markdown keeps writing ordinary repository-relative image paths, so the same file renders on GitHub and on the site. No document carries a site-absolute URL to satisfy VitePress. + +## Alternatives considered + +**Set `publicDir` outside `.generated` and reference site-absolute URLs.** Fewer moving parts in the projector, but every image reference would then be broken when the same Markdown is read in the repository, and canonical docs are read both ways. + +**Host images on the assets branch, as demo GIFs already are.** That branch exists to keep large binaries out of the main history, and its raw URLs have exactly the same visibility problem. It remains the right home for recordings; it does not solve this. + +**Wait for the repository to become public.** It would fix the symptom without making the site self-contained, and the site would silently depend on GitHub's availability and rate limits for every image. + +## Consequences + +Images in published documentation now work regardless of who is reading or whether the repository is public, and the site build has no runtime dependency on GitHub for them. The generated tree grows by one copy of each referenced image per locale — the four screenshots in the model-provider guide add roughly 270 KB per locale. + +Images referenced from *unpublished* documents are untouched: they still resolve to GitHub raw, and still fail for a private repository. Nothing consumes them today, and a document that is not on the site has no site build to carry its assets. + +## Testing + +`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md new file mode 100644 index 0000000000..54aee878a9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 文档站点自带图片 + +Status: implemented + +[English](2026-08-06-doc-site-carries-its-images.md) | 中文 + +## Problem + +`scripts/project-doc-site.ts` 会把发布清单未收录的仓库相对目标一律改写成 GitHub 地址,对图片而言就是 `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`。站点构建不拷贝任何文件:`srcDir` 是用完即弃的 `.generated` 树,VitePress 没有设置 `publicDir`(其默认值 `<srcDir>/public` 恰好位于投影每次运行时删除的那棵树里),而写进去的只有 Markdown。 + +这只对公开仓库成立。本仓库是私有的,而 `raw.githubusercontent.com` 对未认证请求一律回 404——github.com 上的登录会话也不能认证它,因为 GitHub 自家界面是用另一套单独签名的地址提供私有 blob 的。于是站点上的每一张图片对每一位读者都是坏的,却没有任何门禁能说出来:`verify-md-links` 与投影校验的是目标文件**在仓库里是否存在**,那与站点读者能否取到它是两个问题。 + +## Decision + +`rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./<basename>`;随后 Vite 会像处理其他站点资源一样打包它。仓库可见性再也影响不到已发布页面。 + +副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。两个来源若会投影到同一路径则抛错——与既有的重复路由检查同一个立场——而不是让最后拷贝的那个静默胜出。 + +`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 + +正本 Markdown 照旧写普通的仓库相对图片路径,因此同一份文件在 GitHub 上和站点上都能正常显示。没有任何文档为了迁就 VitePress 而写站内绝对 URL。 + +## Alternatives considered + +**把 `publicDir` 设到 `.generated` 之外,并使用站内绝对 URL。** 投影这边的活动部件更少,但同一份 Markdown 在仓库中阅读时,每一处图片引用都会是坏的,而正本文档是两种方式都要读的。 + +**把图片放到 assets 分支,就像演示 GIF 那样。** 那个分支的存在是为了让大体积二进制不进主线历史,而它的 raw 地址有着完全相同的可见性问题。它仍然是录屏的正确归宿;但它解决不了这件事。 + +**等仓库转为公开。** 那只是消除症状,不会让站点自给自足,而且每一张图片都会让站点隐式依赖 GitHub 的可用性与限流。 + +## Consequences + +已发布文档中的图片,现在无论谁在阅读、无论仓库是否公开都能显示,站点构建也不再为图片依赖 GitHub 的运行时可达性。生成树会为每个 locale 各增加一份被引用图片的副本——配置模型指南里的四张截图,每个 locale 约 270 KB。 + +**未发布**文档引用的图片不受影响:它们仍解析到 GitHub raw,对私有仓库仍然失败。今天没有任何消费方用到它们,而不在站点上的文档也没有站点构建可以承载其资源。 + +## Testing + +`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 8a3f518eca..324bcfb5c3 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/user/guide/providers.md -providers.md: c555e62d5343ccc758c0c6e699d30ffec229f2df -providers.zh.md: 6c4154c86db3d95c6b083519533954fc4cc90e45 +providers.md: d96cab0fa09583d81d98863169819fdd78d636e7 +providers.zh.md: d413fec2f9d703e31e82e50fcbe83b24bd58ee39 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index c555e62d53..d96cab0fa0 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -1,4 +1,4 @@ -# Configure model providers +# Configure models English | [中文](providers.zh.md) diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 6c4154c86d..d413fec2f9 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -1,4 +1,4 @@ -# 配置模型提供方 +# 配置模型 [English](providers.md) | 中文 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 74fd06f83d..257f4919cc 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/user/guide/quickstart.md -quickstart.md: 8a9ed716d9395448aadfb97d0935bd42ee06e6c1 -quickstart.zh.md: 3652b0453f870640b278ce6f1355e67e85983ffe +quickstart.md: e81e0ff57384156ee2963d4788519d2384c362ea +quickstart.zh.md: 9755da8bf078c817f9c2a00134360b5169c536bf diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 8a9ed716d9..e81e0ff573 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,6 +57,6 @@ headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead compo ## Next steps -- [Model providers](./providers.md) — reach providers beyond DeepSeek, and custom gateways +- [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3652b0453f..9755da8bf0 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,6 +57,6 @@ headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则组合 [`ap ## 下一步 -- [配置模型提供方](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 +- [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的 tool 或后端 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 185acf2db5..c6402d7fc8 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -93,7 +93,7 @@ describe('rewriteMarkdown', () => { })).toBe('[B](./reference-root/b.md)\n') }) - it('uses raw GitHub content for unpublished images', () => { + it('uses raw GitHub content for unpublished images when nothing places them', () => { const { root, pages } = fixture() expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { locale: 'en', @@ -105,6 +105,39 @@ describe('rewriteMarkdown', () => { })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n') }) + it('hands an image to the placer and uses the URL it returns', () => { + // A raw GitHub URL cannot serve a private repository, so the site build + // carries images itself; the placer is what puts them there. + const { root, pages } = fixture() + const placed: string[] = [] + expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: (absPath) => { + placed.push(absPath.split('/').pop() ?? '') + return './logo.svg' + }, + })).toBe('![logo](./logo.svg)\n') + expect(placed).toEqual(['logo.svg']) + }) + + it('leaves a published page link to the route even when a placer exists', () => { + const { root, pages } = fixture() + expect(rewriteMarkdown('[B](b.md)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: () => { throw new Error('a page link must not be placed as an asset') }, + })).toBe('[B](./reference/b.md)\n') + }) + it('does not rewrite Markdown-looking text inside code fences', () => { const { root, pages } = fixture() const source = '```md\n[B](b.md)\n```\n' diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 592bbcfdee..ef821bd00e 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -5,8 +5,8 @@ * tier, while this adapter rewrites cross-source links for the public site. */ -import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, extname, posix, relative, resolve, sep } from 'node:path' +import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' @@ -38,6 +38,15 @@ export interface RewriteMarkdownOptions { pages: DocsPage[] repoRoot: string repositoryRef: string + /** + * Place one referenced image beside the projected page and return the URL to + * reach it from that page. A GitHub raw URL cannot serve this repository — + * `raw.githubusercontent.com` answers 404 for a private one, and no reader of + * the site is authenticated to it — so an image travels into the generated + * tree and Vite bundles it like any other site asset. Omitted by callers that + * only rewrite text, which then leave images pointing at the repository. + */ + placeImage?: (absPath: string) => string } function repoPath(absPath: string, repoRoot: string): string { @@ -222,9 +231,11 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) ? options.locale === 'root' ? 'en' : 'root' : options.locale const page = published.get(targetPath)?.get(targetLocale) - const nextUrl = page === undefined - ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') - : routeTarget(options.route, page.route, suffix) + const nextUrl = page !== undefined + ? routeTarget(options.route, page.route, suffix) + : node.type === 'image' && options.placeImage !== undefined + ? options.placeImage(absPath) + : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') const start = node.position?.start.offset const end = node.position?.end.offset @@ -299,6 +310,8 @@ export function docsSourceFiles(): string[] { /** Rebuild the disposable VitePress source tree from the publication manifest. */ export function projectDocs(): void { const routes = new Set<string>() + /** Projected asset path to the source it came from, for collision detection. */ + const assets = new Map<string, string>() const repositoryRef = process.env.GITHUB_SHA ?? 'master' rmSync(generatedRoot, { recursive: true, force: true }) @@ -319,6 +332,24 @@ export function projectDocs(): void { pages: docsPages, repoRoot: root, repositoryRef, + placeImage: (absPath) => { + // Beside the page that references it, under its own basename: each + // locale's route tree gets its own copy, so one relative URL is correct + // from both. Two sources that would land on one name are a collision + // rather than a silent overwrite of whichever copied last. + const name = basename(absPath) + const target = resolve(dirname(output), name) + const claimed = assets.get(target) + if (claimed !== undefined && claimed !== absPath) { + throw new Error( + `project-doc-site: ${repoPath(absPath, root)} and ${repoPath(claimed, root)}` + + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + ) + } + assets.set(target, absPath) + copyFileSync(absPath, target) + return `./${name}` + }, }) writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page)) } diff --git a/website/docs.ts b/website/docs.ts index 2b9c4654c4..7bc2225865 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -133,7 +133,7 @@ const homeAndGuide = pairedPages([ { source: 'docs/user/guide/providers.md', route: 'guide/providers.md', - label: { root: '配置模型提供方', en: 'Model providers' }, + label: { root: '配置模型', en: 'Configure models' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 3, From 9ff7eb84f0c3c3ab28ca888db056fb703e2a3ea8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:35:02 +0800 Subject: [PATCH 143/190] docs: propose API key format validation --- ...-08-06-api-key-format-validation.i18n.yaml | 6 ++ .../2026-08-06-api-key-format-validation.md | 101 ++++++++++++++++++ ...2026-08-06-api-key-format-validation.zh.md | 101 ++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml new file mode 100644 index 0000000000..f62a18e0eb --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md +2026-08-06-api-key-format-validation.md: dc19baa8b697998df2892f0840a35a8232cc92de +2026-08-06-api-key-format-validation.zh.md: 28073660b1d4868fecf5ce419726d6d997383392 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md new file mode 100644 index 0000000000..dc19baa8b6 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md @@ -0,0 +1,101 @@ +# Agent Note: Validate API key format before it reaches an HTTP header + +Status: proposed + +English | [中文](2026-08-06-api-key-format-validation.zh.md) + +## Problem + +An API key holding characters no HTTP header value can carry is accepted by every configuration surface and fails only when a request is built, far from the field that caused it. + +Paste a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page and the save reports success. The first turn then fails with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produces this because `fetch` builds the `Bearer` header inside the `try` at [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault is also retried three times. + +`llm-pi-ai` is worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wraps every failure as `could not reach <url>`, so a local key fault is reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sends an illegal key before anything is stored. + +Whitespace passes every check. `ProviderEditor` tests `keyDraft.length` and `resolveAdapterOptions` tests `config.apiKey.length`, so a key of three spaces stores and then authenticates as `Bearer` plus blanks. `llm-pi-ai` rejects an empty literal `apiKey` in `resolveProfiles`, but applies no check whatsoever to a credential- or environment-sourced key — which is the path the Models page writes, and therefore the path users actually take. + +Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. + +## Proposal + +One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. + +This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. + +A second, narrower rule catches a pasted environment line: reject input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. + +### Invariants belong at every layer; heuristics belong where the human is + +The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. + +The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. + +### Absence is a configuration state, not a missing key + +"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. + +**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` exists to carry this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. + +**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` already skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field must never block submit, or editing a base URL would demand re-entering the key. + +**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already words it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. + +`normalizeApiKey` therefore takes `string`, never `string | undefined`. + +### Where the rule lives + +`normalizeApiKey` is a new module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). + +The client cannot import it: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema today. Each side names the other in a comment. + +### What each surface does + +| Surface | Change | +|---|---| +| `dsh-llm` | Add `normalizeApiKey`; add `INVALID_CREDENTIAL`, deliberately outside `DEFAULT_RETRYABLE_CODES`. | +| `llm-deepseek` `resolveAdapterOptions` | Normalize a present `apiKey`, throwing beside the existing beyond-schema bounds; use the trimmed value. An absent one still falls through to `apiKeyEnv`. Closes dsh-external#210. | +| `llm-deepseek` `resolveApiKey` | Normalize what the credentials seam or environment returns; reject with `INVALID_CREDENTIAL` naming the Models page, never echoing the key. | +| `llm-pi-ai` `resolveProfiles` | Widen the existing emptiness check to the shared rule, keeping its "omit it to use ambient authentication" wording. | +| `llm-pi-ai` `resolveApiKey` | Normalize the credential and environment paths, which are unchecked today. A profile naming no credential still returns `undefined` untouched, so ambient and OAuth routes are unaffected. | +| `llm-pi-ai` `discoverModels` | Normalize before building the header, so an illegal key stops reporting as an unreachable endpoint. A probe carrying no key stays unauthenticated as it is today. | +| `ui-models` | Mirror the charset rule, add the shape heuristic, trim `keyDraft` before probe and `credentials.set`, and fix the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure, so typed input is never silently discarded. Gate submit and show the failure on the field, matching the existing `modelFailure` pattern. | + +`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. + +`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stays as it is. + +## Alternatives considered + +**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It loses because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. + +**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. + +**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. + +**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It loses because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. + +**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. + +**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on today's code, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verifies nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this note makes reliable; building it first would produce a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call at save time would be an unexpected behavior rather than a missing one. + +## Acceptance criteria + +- The browser, both resolvers, and both credential reads accept and reject the same *provided* strings: whitespace-only, padded, interior-space, C0 control, emoji, CJK, and full-width inputs are refused; a printable-ASCII key is accepted, trimmed. +- A profile naming no credential still resolves to no key, and a route authenticating through the installed provider's own ambient discovery or OAuth keeps working untouched. +- A blank key field saves the rest of the card without writing a credential; a field holding only whitespace fails on the field instead of being silently dropped. +- A rejected key names the API key field in the web UI and blocks submit; nothing is written to settings or credentials. +- A key that reaches a resolver illegally fails as `INVALID_CREDENTIAL` with a message naming where to fix it, containing no part of the key, and is not retried. +- `llm-pi-ai` discovery reports an illegal key as a key fault, not as an unreachable endpoint. +- A legal key still travels the existing `credentials.set` path unchanged. + +## Risks + +The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. + +Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. + +The charset predicate exists twice, once per source plane. The layout forbids sharing it, and the duplication gate may flag the pair; each side carries its own test and names its twin. + +The costliest way to get this wrong is to treat absence as invalidity. A rule applied to `undefined` would break every route authenticating through ambient discovery or OAuth — `openai-codex` cannot take a key at all — and a blank field that blocked submit would make editing any other setting demand re-entering the key. Both belong in the tests, not only in this note. + +Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value begins failing at resolution rather than at request time. That is the intent — the diagnosis improves — but it moves the failure earlier for anyone currently holding one. diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md new file mode 100644 index 0000000000..28073660b1 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -0,0 +1,101 @@ +# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 + +Status: proposed + +[English](2026-08-06-api-key-format-validation.md) | 中文 + +## Problem + +一个含有 HTTP header value 无法承载的字符的 API Key,会被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 + +把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 + +同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 + +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 + +来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 + +## Proposal + +一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 + +这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 + +第二条更窄的规则用于识别整行粘贴的环境变量:拒绝匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 + +### 不变量属于每一层,启发式属于人所在的那一层 + +字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 + +形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 + +### 「没有 Key」是一种配置状态,不是缺失 + +在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 + +**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 的存在就是为了承载这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 + +**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时本就完全跳过 `credentials.set`,这一点保持不变:留空绝不能拦截提交,否则改一个 base URL 都得重新输一遍 Key。 + +**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面要采用的。 + +因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 + +### 规则住在哪里 + +`normalizeApiKey` 是 `dsh-llm` seam 的新模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 + +客户端无法引入它:client 包只 reference client 包,因此 `packages/client/ui-models` 镜像这个断言并持有本地化文案,正如今天 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 + +### 各个界面各做什么 + +| 界面 | 改动 | +|---|---| +| `dsh-llm` | 新增 `normalizeApiKey`;新增 `INVALID_CREDENTIAL`,刻意不进 `DEFAULT_RETRYABLE_CODES`。 | +| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与既有的超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 仍照旧回落到 `apiKeyEnv`。关闭 dsh-external#210。 | +| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值;以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | +| `llm-pi-ai` `resolveProfiles` | 把既有的空值检查扩展为这条共享规则,并保留其「omit it to use ambient authentication」的措辞。 | +| `llm-pi-ai` `resolveApiKey` | 归一化今天完全未受检的凭据与环境路径。不指定任何凭据的 profile 仍原样返回 `undefined`,ambient 与 OAuth 路由不受影响。 | +| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 不再被报成端点不可达。不带 Key 的探测照旧保持未鉴权。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则以字段级失败呈现,使已输入的内容绝不被静默丢弃。按既有 `modelFailure` 的模式拦截提交并在字段上呈现失败。 | + +`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。 + +`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 + +## Alternatives considered + +**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 + +**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 + +**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 + +**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 + +**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 + +**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在今天的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本 Agent Note 要让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 + +## Acceptance criteria + +- 浏览器、两个 resolver 与两处凭据读取接受与拒绝同一组**已提供**的字符串:纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角输入均被拒绝;可打印 ASCII 的 Key 被接受并 trim。 +- 不指定任何凭据的 profile 仍解析为「没有 Key」,通过内置 provider 自身的 ambient 发现或 OAuth 鉴权的路由原样可用。 +- 留空的 Key 输入框可以保存卡片其余部分而不写入凭据;只含空白的输入框则以字段级失败呈现,而不是被静默丢弃。 +- 被拒绝的 Key 在 Web UI 中定位到 API Key 字段并拦截提交;settings 与凭据均不写入。 +- 非法抵达 resolver 的 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置、不含 Key 的任何片段,且不被重试。 +- `llm-pi-ai` 的探测把非法 Key 报为 Key 故障,而非端点不可达。 +- 合法 Key 仍沿既有 `credentials.set` 路径原样通过。 + +## Risks + +形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 + +限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 + +字符集断言存在两份,每个 source plane 一份。布局禁止共享它,重复检测门禁可能会标记这一对;两侧各自带测试并在注释中指名其孪生体。 + +把这件事做错的最大代价,是把「未指定」当成「非法」。一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由——`openai-codex` 根本无法接受 Key——而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都应落在测试里,而不只是写在本 Agent Note 中。 + +早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。这正是意图所在——诊断变好了——但对当前正持有这类值的人而言,失败点提前了。 From 5b842895a8749f17224263b361ad3696200944cc Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:43:50 +0800 Subject: [PATCH 144/190] feat(llm): define the legal API key shape in the seam --- packages/llm/llm/src/api-key.ts | 41 +++++++++++++++ packages/llm/llm/src/error.ts | 9 ++++ packages/llm/llm/src/index.ts | 34 ++++++++++++- packages/llm/llm/tests/api-key.spec.ts | 70 ++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/llm/llm/src/api-key.ts create mode 100644 packages/llm/llm/tests/api-key.spec.ts diff --git a/packages/llm/llm/src/api-key.ts b/packages/llm/llm/src/api-key.ts new file mode 100644 index 0000000000..85d0b1ed60 --- /dev/null +++ b/packages/llm/llm/src/api-key.ts @@ -0,0 +1,41 @@ +/** + * The one definition of a well-formed provider API key, shared by every + * adapter that puts one in an HTTP header. + * @module @deepseek-ai/dsh-llm/api-key + */ + +/** + * Characters an HTTP header value carries verbatim and every known provider + * key uses: printable ASCII, space excluded. A key outside this set cannot + * reach any provider — `fetch` refuses to build the header — so this is a + * transport invariant rather than one provider's policy. Latin-1 is excluded + * deliberately: a header could carry it, but no provider issues it, and + * admitting it trades a local explained refusal for an opaque 401. + */ +const LEGAL_API_KEY = /^[\x21-\x7E]+$/ + +/** Why a supplied API key cannot be used. */ +export type ApiKeyRejection = 'empty' | 'illegalCharacters' + +/** The verdict on one supplied API key. */ +export type ApiKeyCheck = + | { readonly ok: true; readonly value: string } + | { readonly ok: false; readonly reason: ApiKeyRejection } + +/** + * Judge one *supplied* API key, trimming surrounding whitespace first. + * + * Trimming is silent because a padded key has one unambiguous reading; every + * other defect is reported. Absence is a configuration state this function + * never sees — a profile naming no credential authenticates through the + * provider's own ambient discovery or OAuth — so callers decide whether a + * value was supplied before asking. + * @param raw - the key exactly as configured, stored, or typed. + * @returns the trimmed key, or why it cannot be used. + */ +export function normalizeApiKey(raw: string): ApiKeyCheck { + const value = raw.trim() + if (value.length === 0) return { ok: false, reason: 'empty' } + if (!LEGAL_API_KEY.test(value)) return { ok: false, reason: 'illegalCharacters' } + return { ok: true, value } +} diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index fbb8bccca5..9ff193f1f8 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -38,6 +38,15 @@ export const QUOTA_EXCEEDED_CODE = 'QUOTA' */ export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE' +/** + * Canonical provider-neutral code for a credential that was supplied but + * cannot be used — malformed rather than absent. Distinct from + * `MISSING_CREDENTIAL` because the fix differs: correct the stored value + * rather than supply one. Deliberately outside the default retryable set — + * a malformed credential fails identically on every attempt. + */ +export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL' + /** Structured codes and plain phrases that explicitly name a context bound being exceeded. */ const STRUCTURED_CONTEXT_OVERFLOW = new RegExp( String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 74ca171f64..287bfc2f34 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -25,13 +25,15 @@ import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' -import { HarnessError } from './error.ts' +import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts' import { normalizeLlmFailure } from './adapter-failure.ts' +import { normalizeApiKey } from './api-key.ts' export * from './attribution.ts' export * from './brand.ts' export * from './never.ts' export * from './error.ts' +export * from './api-key.ts' export * from './types.ts' export * from './message.ts' export * from './retry-policy.ts' @@ -122,6 +124,36 @@ export class LlmError extends HarnessError { } } +/** + * Accept one supplied credential, or refuse it as unusable. + * + * A stored key arrives from the credentials seam, a `.env` line, or a shell + * export, all of which pick up surrounding whitespace, so trimming is silent. + * Anything else fails here rather than inside `fetch`, whose ByteString + * refusal names a UTF-16 code point instead of the setting to change. The key + * never enters the message: `ref` names where to fix it, and echoing any part + * of a secret into a log or a UI is the failure this diagnosis avoids. + * + * Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate + * module stays dependency-free; both adapters share this one diagnosis instead + * of keeping near-identical local copies. + * @param raw - the credential exactly as supplied. + * @param pkg - the refusing package name, prefixed to the diagnostic. + * @param ref - the credential reference the value resolved through. + * @returns the trimmed, usable key. + */ +export function assertUsableApiKey(raw: string, pkg: string, ref: string): string { + const checked = normalizeApiKey(raw) + if (checked.ok) return checked.value + throw new LlmError( + checked.reason === 'empty' + ? `${pkg}: the API key stored as ${ref} is blank; re-enter it on the web Models page` + : `${pkg}: the API key stored as ${ref} contains characters no HTTP header can carry;` + + ' re-enter it on the web Models page, pasting the raw key only', + INVALID_CREDENTIAL_CODE, + ) +} + /** One model call whose config and adapter registration were resolved together. */ export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ diff --git a/packages/llm/llm/tests/api-key.spec.ts b/packages/llm/llm/tests/api-key.spec.ts new file mode 100644 index 0000000000..a04a103fb9 --- /dev/null +++ b/packages/llm/llm/tests/api-key.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { assertUsableApiKey, INVALID_CREDENTIAL_CODE, normalizeApiKey } from '@deepseek-ai/dsh-llm' + +describe('normalizeApiKey', () => { + it('accepts a printable-ASCII key unchanged', () => { + expect(normalizeApiKey('sk-0123456789abcdef')).toEqual({ ok: true, value: 'sk-0123456789abcdef' }) + }) + + it('trims surrounding whitespace before judging', () => { + expect(normalizeApiKey(' sk-abc\t\n')).toEqual({ ok: true, value: 'sk-abc' }) + }) + + it.each([ + ['an empty string', ''], + ['spaces only', ' '], + ['a tab only', '\t'], + ])('rejects %s as empty', (_label, raw) => { + expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'empty' }) + }) + + it.each([ + ['an emoji', 'sk-\u{1F600}abc'], + ['CJK text', 'sk-你好'], + ['full-width punctuation', 'sk-abc,'], + ['an interior space', 'sk-abc def'], + ['a C0 control character', 'sk-abc\x01'], + ['a latin-1 character', 'sk-café'], + ])('rejects %s as illegal characters', (_label, raw) => { + expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'illegalCharacters' }) + }) + + it('accepts the printable-ASCII boundary characters', () => { + expect(normalizeApiKey('!~')).toEqual({ ok: true, value: '!~' }) + }) + + it('publishes a code distinct from a missing credential', () => { + expect(INVALID_CREDENTIAL_CODE).toBe('INVALID_CREDENTIAL') + }) +}) + +describe('assertUsableApiKey', () => { + it('returns the trimmed key when it is usable', () => { + expect(assertUsableApiKey(' sk-abc ', 'llm-deepseek', 'DEEPSEEK_API_KEY')).toBe('sk-abc') + }) + + it('refuses a blank stored credential, naming the reference', () => { + expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY')) + .toThrow(/llm-deepseek: the API key stored as DEEPSEEK_API_KEY is blank/) + }) + + it('refuses an unusable stored credential with the invalid-credential code', () => { + try { + assertUsableApiKey('sk-\u{1F600}', 'llm-pi-ai', 'ACME_API_KEY') + expect.fail('an illegal key must throw') + } catch (error) { + expect((error as { code: string }).code).toBe(INVALID_CREDENTIAL_CODE) + expect((error as Error).message).toContain('llm-pi-ai') + expect((error as Error).message).toContain('ACME_API_KEY') + } + }) + + it('never echoes the key it refuses', () => { + try { + assertUsableApiKey('sk-\u{1F600}supersecret', 'llm-deepseek', 'DEEPSEEK_API_KEY') + expect.fail('an illegal key must throw') + } catch (error) { + expect((error as Error).message).not.toContain('supersecret') + } + }) +}) From 88f5de57559d76fdcefd6d79621c466534c9996d Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:57:30 +0800 Subject: [PATCH 145/190] docs: regenerate cordis catalog and event graph for shifted index.ts lines --- docs/cordis-catalog/events.md | 4 ++-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 44e0727101..b8eae843cc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -493,7 +493,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:71`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) ### `llm/stream` — waterfall @@ -517,7 +517,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 896c17fb56..6b30d80751 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -941,7 +941,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk> Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:255`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:287`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3de02d1b4c..b2d8feaa1d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,8 +28,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | From b6b57ceda3c4b1a71b8741361b538699e2bcd2f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 20:57:43 +0800 Subject: [PATCH 146/190] docs(llm): document the invalid-credential code --- packages/llm/llm/README.i18n.yaml | 4 ++-- packages/llm/llm/README.md | 5 +++++ packages/llm/llm/README.zh.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 5e4daa179b..efdb8ea511 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd -README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972 +README.md: 618d5f9f7c69c3ff2b420ae3fec96604802bf1be +README.zh.md: 4b99c477eae694d1315d90920e835fcfde3b571a diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index ca34ffdeaa..618d5f9f7c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -63,6 +63,10 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). +### API key validation (`api-key.ts`) + +Every adapter that puts a credential in an HTTP header judges it the same way before use. `normalizeApiKey(raw)` trims surrounding whitespace, then accepts any non-empty printable-ASCII value (`/^[\x21-\x7E]+$/`, space excluded) or reports why not as an `ApiKeyRejection` (`'empty'` | `'illegalCharacters'`), both carried in the `ApiKeyCheck` result. Absence is never judged: a caller decides whether a value was supplied before asking, since a profile naming no credential authenticates through the provider's own ambient discovery or OAuth. + ### Classes - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. @@ -73,6 +77,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. - `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits. - `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default. +- `INVALID_CREDENTIAL_CODE` — the provider-neutral code for a credential that was supplied but cannot be used: malformed rather than absent, so the fix is to correct the stored value rather than supply one — the distinction from `MISSING_CREDENTIAL`. Deliberately excluded from the default retryable set, since a malformed credential fails identically on every attempt. `assertUsableApiKey(raw, pkg, ref)` throws `LlmError` with this code, the one shared diagnosis every adapter uses for an unusable stored credential. ### Real adapters diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 1f95d3cd64..4b99c477ea 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -63,6 +63,10 @@ 每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +### API 密钥校验(`api-key.ts`) + +每个要把凭据放进 HTTP 标头的适配器,使用前都以同一套规则校验它。`normalizeApiKey(raw)` 先去除首尾空白,再接受任意非空的可打印 ASCII 值(`/^[\x21-\x7E]+$/`,不含空格),否则以 `ApiKeyRejection`(`'empty'` | `'illegalCharacters'`)说明拒绝原因,二者一并包含在 `ApiKeyCheck` 结果中。缺失从不参与校验:调用方会在询问之前自行判断是否提供了值——未点名凭据的 profile 会转由提供方自身的环境发现或 OAuth 完成认证。 + ### 类 - `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`。 @@ -73,6 +77,7 @@ - `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过 HTTP 异常抛出还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。 - `QUOTA_EXCEEDED_CODE`:帐户配额、余额、点数、预算或用量限制耗尽时使用的非短暂提供方无关 code。`isQuotaExceededError(detail)` 使这些失败与请求速率限制保持区分。 - `EMPTY_RESPONSE_CODE`:两个适配器都使用的提供方无关 code,用于表示退化的提供方生成结果:一个未携带任何内容块的终止 `stop`。它会被分类为错误 finish(而非成功空消息),因为尝试未产生持久内容;`dsh-llm-retry` 默认重试它。 +- `INVALID_CREDENTIAL_CODE`:已提供但无法使用的凭据所用的提供方无关 code——格式错误而非缺失,修复方式是改正已存储的值而非补供一个,这正是它与 `MISSING_CREDENTIAL` 的区别。它被刻意排除在默认可重试集合之外:格式错误的凭据每次尝试都会以同样方式失败。`assertUsableApiKey(raw, pkg, ref)` 会以该 code 抛出 `LlmError`,是每个适配器判定已存储凭据不可用时共用的诊断。 ### 真实适配器 From a48b84c001c885b2dc209bbcef2f6b87a03cc7c4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 21:14:39 +0800 Subject: [PATCH 147/190] fix(scripts): only publish images the repository owns, and keep their suffix Review found four real gaps in the image placement this PR introduced. Link rewriting only needs a target to exist, but publication copies its bytes onto the site: a reference reaching out of the tree through `../..` or a symlink would put a build-machine file on a published page. Only a regular file whose real path stays inside the repository is copied now, and anything else fails the projection naming the page and the target. A placed reference kept none of its `?query` or `#fragment`, which the GitHub branch has always carried and which decides what an SVG view fragment or a Vite query means. The suffix rides along again, and the file name is percent-encoded because the destination is a Markdown inline target. Page outputs and placed images now claim projected paths from one map, so the "fail loud rather than overwrite" invariant covers a page and an image landing on one path, not only two images. `docsSourceFiles()` reports placed images, so replacing a screenshot re-projects under `docs:dev` instead of serving the previous copy until something touches the page. The guide said to set `agent-loop`'s `agents` to change the default model, which does nothing for `dsh web`: that default is `api-gateway`'s, and the shipped composition leaves `agents` empty. It also promised that a catalog provider needs only an API key, which is false for Bedrock, Vertex, Azure, and Codex. Both are corrected. The projection note and the doc-site skill carried the superseded "a repository image becomes a raw GitHub URL" rule; both now describe what ships. --- ...13-documentation-site-projection.i18n.yaml | 4 +- ...026-07-13-documentation-site-projection.md | 2 +- ...-07-13-documentation-site-projection.zh.md | 2 +- ...8-06-doc-site-carries-its-images.i18n.yaml | 4 +- .../2026-08-06-doc-site-carries-its-images.md | 8 +- ...26-08-06-doc-site-carries-its-images.zh.md | 8 +- .agents/skills/dsh-doc-site-sync/SKILL.md | 1 + docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 17 +-- docs/user/guide/providers.zh.md | 17 +-- scripts/project-doc-site.spec.ts | 56 +++++++++- scripts/project-doc-site.ts | 105 ++++++++++++++---- 12 files changed, 178 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 74ded5f605..7fa4d3fbba 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01 -2026-07-13-documentation-site-projection.zh.md: 6f1c79ac502a04714cd77f680108dbff035b048c +2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0 +2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index 2452c9dfa5..f19d9b309a 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,7 +18,7 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. -The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 6f1c79ac50..cc5e94e709 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,7 +18,7 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml index 75018c8374..32b51699e2 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-08-06-doc-site-carries-its-images.md -2026-08-06-doc-site-carries-its-images.md: 21593c2cadb6b2aaf52350ab61156ad892bc4163 -2026-08-06-doc-site-carries-its-images.zh.md: 54aee878a9d0f16d1fe3b219da7b248fb5148fa3 +2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42 +2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md index 21593c2cad..9109808874 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -14,7 +14,11 @@ That works only for a public repository. This one is private, and `raw.githubuse `rewriteMarkdown` takes an optional `placeImage(absPath): string`. When a page references an image the manifest does not publish as a page, the projector copies that file into the generated tree beside the page and rewrites the reference to `./<basename>`; Vite then bundles it like any other site asset. Nothing about repository visibility can reach the published page. -The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. Two sources that would project onto one path throw, in the same spirit as the existing duplicate-route check, rather than letting whichever copied last win. +The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. One map claims every projected path — pages and images alike — so a second source for one path throws, in the same spirit as the existing duplicate-route check, rather than letting whichever wrote last win. + +Only a regular file whose real path stays inside the repository is copied; anything else fails the projection naming the page and the target. Link rewriting needs to know a target *exists*, but publication copies its bytes onto the site, so a reference escaping the repository — through `../..` or a symlink out of the tree — would put a build-machine file on a published page. The reference's `?query` or `#fragment` rides along to the placed URL exactly as the GitHub branch has always carried it, and the file name is percent-encoded because the destination is a Markdown inline target. + +`docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page. `placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. @@ -36,4 +40,4 @@ Images referenced from *unpublished* documents are untouched: they still resolve ## Testing -`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). +`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a placed reference keeping its fragment, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `publishableImage` is covered directly: a regular file inside the repository resolves, while a symlink whose target escapes it, a path outside it, and a directory are all refused. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md index 54aee878a9..d601112e88 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -14,7 +14,11 @@ Status: implemented `rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./<basename>`;随后 Vite 会像处理其他站点资源一样打包它。仓库可见性再也影响不到已发布页面。 -副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。两个来源若会投影到同一路径则抛错——与既有的重复路由检查同一个立场——而不是让最后拷贝的那个静默胜出。 +副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。一张表登记所有被投影的路径——页面与图片一视同仁——同一路径出现第二个来源就抛错,与既有的重复路由检查同一个立场,而不是让最后写入的那个静默胜出。 + +只有真实路径位于仓库内的普通文件才会被拷贝,其余一律让投影失败并点名页面与目标。链接改写只需要知道目标**存在**,但发布是把它的字节拷上站点,因此一个逃出仓库的引用——经由 `../..` 或指向树外的符号链接——会把构建机上的文件放到已发布页面上。引用自带的 `?query` 或 `#fragment` 会随安置后的 URL 一同保留,与 GitHub 分支一贯的做法一致;文件名做百分号编码,因为目标位于 Markdown 内联目标的位置。 + +`docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。 `placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 @@ -36,4 +40,4 @@ Status: implemented ## Testing -`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 +`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、被安置的引用保留其 fragment、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`publishableImage` 另有直接覆盖:仓库内的普通文件被接受,而目标逃出仓库的符号链接、仓库外的路径与目录一律拒绝。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 4d88d3f04f..3f93a6560a 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -46,6 +46,7 @@ Write normal repository-relative Markdown links in canonical docs. The projector - A target present in the manifest becomes a site-relative route. - An existing target outside the manifest becomes a GitHub source link, including supported line suffixes. +- An image is the exception: its file is copied into the generated tree and referenced from there, so the site serves it regardless of repository visibility. It must be a regular file inside the repository. - External URLs, site-absolute URLs, email links, and fragment-only links remain unchanged. - A missing repository-relative target fails projection instead of silently producing a broken link. diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 324bcfb5c3..665eae8457 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/user/guide/providers.md -providers.md: d96cab0fa09583d81d98863169819fdd78d636e7 -providers.zh.md: d413fec2f9d703e31e82e50fcbe83b24bd58ee39 +providers.md: 66b6cf25c61a252fbd10a85f8c79c246eeae8abe +providers.zh.md: a2c33c90be971e09ab29e2355ca6a7ae6f947c39 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index d96cab0fa0..66b6cf25c6 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -23,6 +23,8 @@ Start `pnpm run dsh web` and open **Settings → Models**. **Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. +That holds for providers that authenticate with an API key. The catalog also carries Bedrock, Vertex, Azure, and Codex, which need AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively: filling in the key field alone will not make them work. Those authenticate through pi-ai's own environment discovery, with credentials prepared the way each one requires. + **Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. ![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) @@ -93,18 +95,19 @@ References resolve from `$DSH_HOME/.env` — what the Models page's key fields w ## Point an agent at the new provider -A configured route appears in the web model picker and can be switched at any time. To change the default, edit the `agent-loop` entry's `provider` and `model` in `cordis.yml`: +A configured route appears in the web model picker and can be switched at any time, which is how most people use it. + +A new session's default model comes from the `api-gateway` entry (`@deepseek-ai/dsh-host-apiproxy`) and its `provider` and `model`, which ship as `deepseek-official` and `deepseek-v4-flash`. To change that default, override the entry in `$DSH_HOME/config.yaml`: ```yaml -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +- id: api-gateway config: - agents: - - id: main - provider: acme-gateway - model: acme-large + provider: acme-gateway + model: acme-large ``` +A patch replaces that entry's whole `config`, so write out every key it needs to keep. A composition you assemble yourself — headless, for instance — sets `agent-loop`'s `agents` instead. + ## Troubleshooting - **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index d413fec2f9..a2c33c90be 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -23,6 +23,8 @@ Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器 **添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 +只对以 API 密钥认证的提供方成立。目录里也有 Bedrock、Vertex、Azure、Codex:它们分别需要 AWS 凭据与区域、ADC 项目配置、`api-version`、OAuth,只填密钥框不会让它们工作——这类提供方靠 pi-ai 自己的环境发现认证,凭据按各自的原生方式准备。 + **添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 ![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) @@ -93,18 +95,19 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 让 agent 用上新提供方 -配好的路由会出现在 Web 的模型选择器里,随时可切。要改默认值,就在 `cordis.yml` 里改 `agent-loop` 那条的 `provider` 与 `model`: +配好的路由会出现在 Web 的模型选择器里,随时可切,这也是最常用的方式。 + +新会话的默认模型来自 `api-gateway` 那条(`@deepseek-ai/dsh-host-apiproxy`)的 `provider` 与 `model`,出厂值是 `deepseek-official` 与 `deepseek-v4-flash`。要改默认值,就在 `$DSH_HOME/config.yaml` 里覆盖该条: ```yaml -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +- id: api-gateway config: - agents: - - id: main - provider: acme-gateway - model: acme-large + provider: acme-gateway + model: acme-large ``` +补丁会整体替换该条的 `config`,所以要把这条需要保留的键一并写出。自行组装的 `cordis.yml`(例如 headless)改的则是 `agent-loop` 的 `agents`。 + ## 排错 - **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index c6402d7fc8..6770381526 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -1,12 +1,14 @@ /** Tests for the documentation website projection adapter. */ import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' -import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts' +import { + addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown, +} from './project-doc-site.ts' const roots: string[] = [] const repositoryRoot = resolve(import.meta.dirname, '..') @@ -63,6 +65,32 @@ describe('website source layout', () => { }) }) +describe('publishableImage', () => { + it('accepts a regular file inside the repository', () => { + const { root } = fixture() + const real = realpathSync(join(root, 'packages/logo.svg')) + expect(publishableImage(join(root, 'packages/logo.svg'), realpathSync(root))).toBe(real) + }) + + it('refuses a target whose real path escapes the repository', () => { + // Publication copies the bytes onto the site, so a reference reaching a + // build-machine file must not be treated as an image the repository owns. + const { root } = fixture() + const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-site-outside-')) + roots.push(outside) + writeFileSync(join(outside, 'secret.png'), 'not really a png\n') + symlinkSync(join(outside, 'secret.png'), join(root, 'packages/linked.png')) + + expect(publishableImage(join(root, 'packages/linked.png'), realpathSync(root))).toBeUndefined() + expect(publishableImage(join(outside, 'secret.png'), realpathSync(root))).toBeUndefined() + }) + + it('refuses a directory', () => { + const { root } = fixture() + expect(publishableImage(join(root, 'packages'), realpathSync(root))).toBeUndefined() + }) +}) + describe('rewriteMarkdown', () => { it('maps published pages and pins unpublished source links', () => { const { root, pages } = fixture() @@ -107,7 +135,9 @@ describe('rewriteMarkdown', () => { it('hands an image to the placer and uses the URL it returns', () => { // A raw GitHub URL cannot serve a private repository, so the site build - // carries images itself; the placer is what puts them there. + // carries images itself; the placer is what puts them there. The stand-in + // derives its URL the way the real one does, so a placer that stopped + // returning the basename would fail here rather than pass on a constant. const { root, pages } = fixture() const placed: string[] = [] expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { @@ -118,13 +148,29 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - placed.push(absPath.split('/').pop() ?? '') - return './logo.svg' + const name = absPath.split('/').pop() ?? '' + placed.push(name) + return `./${name}` }, })).toBe('![logo](./logo.svg)\n') expect(placed).toEqual(['logo.svg']) }) + it('keeps a placed image\u2019s query or fragment', () => { + // An SVG view fragment and a Vite query both change what the reference + // means, and the GitHub branch has always carried them. + const { root, pages } = fixture() + expect(rewriteMarkdown('![logo](../packages/logo.svg#view)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + })).toBe('![logo](./logo.svg#view)\n') + }) + it('leaves a published page link to the route even when a placer exists', () => { const { root, pages } = fixture() expect(rewriteMarkdown('[B](b.md)\n', { diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index ef821bd00e..02a64b023a 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -5,7 +5,9 @@ * tier, while this adapter rewrites cross-source links for the public site. */ -import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, +} from 'node:fs' import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' @@ -234,7 +236,9 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) const nextUrl = page !== undefined ? routeTarget(options.route, page.route, suffix) : node.type === 'image' && options.placeImage !== undefined - ? options.placeImage(absPath) + // The suffix rides along exactly as the GitHub branch keeps it: an SVG + // view fragment or a Vite query changes what the reference means. + ? `${options.placeImage(absPath)}${suffix}` : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') const start = node.position?.start.offset @@ -302,19 +306,78 @@ export function projectedPageContent(markdown: string, page: DocsPage): string { return markdown.slice(0, closing + closingDelimiter.length) } -/** Canonical Markdown files watched by the local VitePress dev server. */ +/** + * The repository file one image reference resolves to, or `undefined` when the + * target is not a local file this build may publish. + * @param absPath - resolved image target. + * @param repoRoot - repository root every published image must stay inside. + * @returns the file's real path, or `undefined` when it must not be copied. + * + * Only a regular file whose real path stays inside the repository qualifies. + * Publication copies the bytes into the site, so a reference escaping the + * repository — `../../.ssh/id_rsa`, or a symlink pointing out of the tree — + * would put a build-machine file on the site; `existsSync` alone, which is all + * link resolution needs, does not answer that. + */ +export function publishableImage(absPath: string, repoRoot: string): string | undefined { + const real = realpathSync(absPath) + const inside = real === repoRoot || real.startsWith(`${repoRoot}${sep}`) + return inside && statSync(real).isFile() ? real : undefined +} + +/** Every local image a published page references, resolved to its repository file. */ +function referencedImages(): string[] { + const found = new Set<string>() + for (const page of docsPages) { + const sourceAbs = resolve(root, page.source) + if (!existsSync(sourceAbs)) continue + rewriteMarkdown(readFileSync(sourceAbs, 'utf8'), { + sourcePath: page.source, + locale: page.locale, + route: page.route, + pages: docsPages, + repoRoot: root, + repositoryRef: 'master', + placeImage: (absPath) => { + const real = publishableImage(absPath, root) + if (real !== undefined) found.add(real) + return '' + }, + }) + } + return [...found] +} + +/** + * Files watched by the local VitePress dev server: every canonical Markdown + * source, plus the images they publish. Without the images, replacing a + * screenshot leaves the previous copy in the generated tree until something + * touches the Markdown beside it. + */ export function docsSourceFiles(): string[] { - return [...new Set(docsPages.map(page => resolve(root, page.source)))] + return [...new Set([...docsPages.map(page => resolve(root, page.source)), ...referencedImages()])] } /** Rebuild the disposable VitePress source tree from the publication manifest. */ export function projectDocs(): void { const routes = new Set<string>() - /** Projected asset path to the source it came from, for collision detection. */ - const assets = new Map<string, string>() + /** Projected path to the repository file that claimed it, pages and images alike. */ + const claimed = new Map<string, string>() const repositoryRef = process.env.GITHUB_SHA ?? 'master' rmSync(generatedRoot, { recursive: true, force: true }) + /** Reserve one projected path, refusing a second source for it. */ + const claim = (target: string, sourceAbs: string): void => { + const holder = claimed.get(target) + if (holder !== undefined && holder !== sourceAbs) { + throw new Error( + `project-doc-site: ${repoPath(sourceAbs, root)} and ${repoPath(holder, root)}` + + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + ) + } + claimed.set(target, sourceAbs) + } + for (const page of docsPages) { if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`) routes.add(page.route) @@ -323,6 +386,9 @@ export function projectDocs(): void { throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`) } const output = resolve(generatedRoot, page.route) + // Claimed before the images are placed: a page and an image landing on one + // path would otherwise overwrite each other in whichever order they ran. + claim(output, sourceAbs) mkdirSync(dirname(output), { recursive: true }) const markdown = readFileSync(sourceAbs, 'utf8') const projected = rewriteMarkdown(markdown, { @@ -333,22 +399,23 @@ export function projectDocs(): void { repoRoot: root, repositoryRef, placeImage: (absPath) => { - // Beside the page that references it, under its own basename: each - // locale's route tree gets its own copy, so one relative URL is correct - // from both. Two sources that would land on one name are a collision - // rather than a silent overwrite of whichever copied last. - const name = basename(absPath) - const target = resolve(dirname(output), name) - const claimed = assets.get(target) - if (claimed !== undefined && claimed !== absPath) { + const real = publishableImage(absPath, root) + if (real === undefined) { throw new Error( - `project-doc-site: ${repoPath(absPath, root)} and ${repoPath(claimed, root)}` - + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + `project-doc-site: ${page.source} references image ${repoPath(absPath, root)},` + + ' which is not a regular file inside the repository.', ) } - assets.set(target, absPath) - copyFileSync(absPath, target) - return `./${name}` + // Beside the page that references it, under its own basename: each + // locale's route tree gets its own copy, so one relative URL is correct + // from both. + const name = basename(real) + const target = resolve(dirname(output), name) + claim(target, real) + copyFileSync(real, target) + // Encoded because the destination is a Markdown inline target, where an + // unescaped space would end it early. + return `./${encodeURI(name)}` }, }) writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page)) From f00a44fd449f221e5548c1b040d9bc9264bafa44 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:21:19 -0700 Subject: [PATCH 148/190] refactor(web): move the produced-files row into its own plugin package ui-conversation now owns only the conversation.chat.turnTail hole; the row, its derivation, and its copy live in @deepseek-ai/dsh-client-ui-deliverables, composed in or out by one cordis.yml line. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 2 +- .../2026-07-31-web-workspace-file-links.zh.md | 2 +- apps/cli/config/web.cordis.yml | 5 + apps/cli/package.json | 1 + docs/config-catalog.md | 1 + knip.json | 10 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/chat/AssistantMarkdown.tsx | 17 +- .../src/client/chat/ChatView.tsx | 10 +- .../src/client/chat/Deliverables.tsx | 53 ----- .../src/client/chat/chat-flow.ts | 77 -------- .../src/client/contract/slots.ts | 31 ++- .../ui-conversation/src/client/index.ts | 2 +- .../ui-conversation/src/client/locales.ts | 6 - .../ui-conversation/tests/chat-view.spec.tsx | 93 +-------- .../client/ui-deliverables/README.i18n.yaml | 6 + packages/client/ui-deliverables/README.md | 21 ++ packages/client/ui-deliverables/README.zh.md | 21 ++ packages/client/ui-deliverables/package.json | 65 +++++++ .../src/client/ProducedFiles.module.css} | 0 .../src/client/ProducedFiles.tsx | 61 ++++++ .../ui-deliverables/src/client/index.ts | 42 ++++ .../ui-deliverables/src/client/locales.ts | 21 ++ .../src/client/turn-deliverables.ts | 78 ++++++++ .../ui-deliverables/src/css-modules.d.ts | 6 + packages/client/ui-deliverables/src/index.ts | 9 + .../client/ui-deliverables/src/invariant.ts | 32 +++ .../tests/produced-files.spec.tsx | 183 ++++++++++++++++++ packages/client/ui-deliverables/tsconfig.json | 30 +++ .../client/ui-deliverables/tsdown.config.ts | 3 + pnpm-lock.yaml | 34 ++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 38 files changed, 685 insertions(+), 253 deletions(-) delete mode 100644 packages/client/ui-conversation/src/client/chat/Deliverables.tsx create mode 100644 packages/client/ui-deliverables/README.i18n.yaml create mode 100644 packages/client/ui-deliverables/README.md create mode 100644 packages/client/ui-deliverables/README.zh.md create mode 100644 packages/client/ui-deliverables/package.json rename packages/client/{ui-conversation/src/client/chat/Deliverables.module.css => ui-deliverables/src/client/ProducedFiles.module.css} (100%) create mode 100644 packages/client/ui-deliverables/src/client/ProducedFiles.tsx create mode 100644 packages/client/ui-deliverables/src/client/index.ts create mode 100644 packages/client/ui-deliverables/src/client/locales.ts create mode 100644 packages/client/ui-deliverables/src/client/turn-deliverables.ts create mode 100644 packages/client/ui-deliverables/src/css-modules.d.ts create mode 100644 packages/client/ui-deliverables/src/index.ts create mode 100644 packages/client/ui-deliverables/src/invariant.ts create mode 100644 packages/client/ui-deliverables/tests/produced-files.spec.tsx create mode 100644 packages/client/ui-deliverables/tsconfig.json create mode 100644 packages/client/ui-deliverables/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 2b75bc2eff..e38447bf37 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: da99426ecb5ca81dcc110bbd4d5c1218390ae4bd -2026-07-31-web-workspace-file-links.zh.md: 91aa94c6fe253c64125eb31fd15973a5aaff1a8f +2026-07-31-web-workspace-file-links.md: 5bac48286c0d9066154a649f418b2f9c2df36539 +2026-07-31-web-workspace-file-links.zh.md: 2b2802ad00bbf4eb84d7ad81bca3b4a0838092c9 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index da99426ecb..5bac48286c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -14,7 +14,7 @@ Two distinct defects sat behind that. The transcript never said what a turn had ## Decision -**A finished turn ends with the files it produced.** `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the seq `assistantActionsSeqs` already elects. +**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. One cordis.yml line composes the surface in or out; the unregistered hole renders nothing. **The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 91aa94c6fe..2b2802ad00 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决定 -**完成的一轮以它产出的文件收尾。** `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 早已选出的那个 seq。 +**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。 **路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index daf597916e..62731fd51e 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -141,6 +141,11 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' + # Turn tail: the produced-files row under each closing assistant message. + # Remove this entry to turn the surface off; the tail hole renders empty. + - id: ui-deliverables + name: '@deepseek-ai/dsh-client-ui-deliverables' + - id: ui-workspace name: '@deepseek-ai/dsh-client-ui-workspace' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4ba1da7e86..58b92bc41d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 89f1529387..710022c11c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2376,6 +2376,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) diff --git a/knip.json b/knip.json index dfb8058d7c..b16e085723 100644 --- a/knip.json +++ b/knip.json @@ -127,6 +127,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-deliverables": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/web-react": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f1e7c35db7..7c0fd0e42a 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: 674cbe7ddf6f2ddc64c337cbebb4ee553e1e3f96 -README.zh.md: d3e8475c7670c57575aa8eeecbf29646c6672c2f +README.md: 8e31a41ad682dfa21d22c93673b17954a784dd4f +README.zh.md: 05b3eeb3185166a1f16596a206ef4111a50e789d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 674cbe7ddf..8e31a41ad6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,7 +50,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` 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 and the store factory stay internal and reach the page through apply's slot registrations. -A finished turn ends with the files it produced. `chat-flow.ts`'s `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the same seq `assistantActionsSeqs` elects. It shows six chips (basename, full path as the title) plus an explicit remainder count, and each chip opens through the same `openFile` the tool rows use. +A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d3e8475c76..05b3eeb318 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,7 +50,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 -完成的一轮以它产出的文件收尾。`chat-flow.ts` 的 `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 选出的同一个 seq。它展示六枚 chip(文本为文件名,完整路径作为 title),外加一个显式的剩余计数,每枚 chip 都经由工具行所用的同一个 `openFile` 打开。 +完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..8eb78139c4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -303,6 +303,7 @@ export function apply(ctx: Context): void { children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 77cd3316d8..d6b6504bd0 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,13 +9,12 @@ // only when the node is also the completed turn's transcript tail. Think / // tool-head-only nodes stay chrome-free. -import { memo, useMemo } from 'react' +import { memo, useMemo, type ReactNode } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { Deliverables } from './Deliverables.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -39,11 +38,9 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined - /** Files the closing turn produced, listed under the body; omitted for a - * mid-turn assistant and for a turn that wrote nothing. */ - produced?: readonly string[] | undefined - /** Opens one produced file; omitted wherever `produced` is. */ - openFile?: ((path: string) => void) | undefined + /** Turn-tail content (the chat view's turnTail hole, rendered by the + * owner); omitted for a mid-turn assistant. */ + tail?: ReactNode | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -92,7 +89,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, produced, openFile, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, tail, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -130,9 +127,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && <span className={css.stopped}>{t('message.stopped')}</span>} </div> - {showActions && produced !== undefined && openFile !== undefined && ( - <Deliverables paths={produced} openFile={openFile} t={t} /> - )} + {showActions && tail} {showActions && ( <MessageIconActions text={copyText(blocks)} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 725a7403e2..3e0015ed20 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, turnDeliverables, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -361,9 +361,6 @@ export function ChatView({ // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) - // Produced files per closing assistant: derived from the mutation tools' - // locations, so a turn's output is listed whether or not the model named it. - const produced = useMemo(() => turnDeliverables(nodes), [nodes]) const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) @@ -621,8 +618,9 @@ export function ChatView({ seq={node.seq} onFork={forkAt} forkUnavailable={!branchSeqs.has(node.seq)} - produced={produced.get(node.seq)} - openFile={openFile} + tail={actionSeqs.has(node.seq) + ? renderSlot('conversation.chat.turnTail', { nodes, seq: node.seq, openFile }) + : undefined} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx deleted file mode 100644 index 7d62e23401..0000000000 --- a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// Deliverables: the produced-file row a finished turn ends with. The paths come -// from the mutation tools' follow-along locations (see turnDeliverables), never -// from the closing prose, so the answer carries its own output whether or not -// the model remembered to name it. Clicking one goes through the same openFile -// the tool rows use — the Host's own opener, on the Host machine. - -import type { ChatViewSlotProps } from '../contract/slots.ts' -import css from './Deliverables.module.css' - -/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ -const SHOWN = 6 - -/** Trailing path segment, the part that identifies the file at a glance. */ -function basename(path: string): string { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - return at === -1 ? path : path.slice(at + 1) -} - -/** - * Render one turn's produced files as openable chips. - * @param props - the turn's paths (tool order, already deduped), the chat - * view's file opener, and the owning view's locale seat. - * @returns The row, or `null` when the turn produced nothing. - */ -export function Deliverables({ paths, openFile, t }: { - paths: readonly string[] - openFile: (path: string) => void - t: ChatViewSlotProps['t'] -}) { - if (paths.length === 0) return null - const shown = paths.slice(0, SHOWN) - const hidden = paths.length - shown.length - return ( - <div className={css.root}> - <span className={css.label}>{t('produced.label')}</span> - {shown.map(path => ( - <button - key={path} - type="button" - className={css.file} - // The full path is the disambiguator when two turns produce files - // that share a basename; the chip itself stays short. - title={path} - aria-label={t('produced.open', { name: path })} - onClick={() => { openFile(path) }} - > - {basename(path)} - </button> - ))} - {hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>} - </div> - ) -} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 7d5e5100f6..57d2ac1bb0 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -32,21 +32,6 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } -/** - * Paths a call view reports having created or changed, by render intent rather - * than tool name: a diff card, or a generic card whose kind is `edit` (the - * shape `str_replace_editor`'s insert presents). Every other card produces - * nothing to open — a read looked, a delete removed, a terminal ran. - */ -function producedPaths(view: ToolResultNode['callView']): readonly string[] { - if (view === null) return [] - if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) - if (view.card === 'generic' && view.kind === 'edit') { - return (view.locations ?? []).map(location => location.path) - } - return [] -} - /** * Seq set of assistants that own IconActions: the last content-text assistant * in each turn. Mid-turn narration (text before tools) stays chrome-free. @@ -62,68 +47,6 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } -/** - * Files each turn produced, keyed by the assistant seq that closes it — the - * same anchor {@link assistantActionsSeqs} elects, so the row lands under the - * message that reports the work rather than after some mid-turn narration. - * - * The source is the mutation tools' own follow-along `locations`, not the - * closing prose: a produced file must be listed whether or not the model - * remembered to name it. A mutation is recognized by render intent, not by - * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape - * `str_replace_editor`'s insert presents) — so a new mutation tool joins by - * declaring what it does. Reads contribute nothing (looking at a file does not - * produce it), and neither do deletes (there is nothing left to open) or - * failed calls. Paths keep first-seen order and appear once, so a file written - * and then edited in the same turn is one entry. - * - * Accumulation resets on the turn boundary, not merely at the closing - * assistant: a turn that mutates files and then ends without content text - * (interrupted mid-tool, or a turn whose last text precedes its last tool - * result) must not spill its paths into the next turn's row, nor leave `seen` - * suppressing a file the next turn legitimately rewrites. - * @param nodes - snapshot nodes (surface order). - * @returns Per-closing-seq produced paths; a turn that produced none is absent. - */ -export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMap<number, readonly string[]> { - const closing = assistantActionsSeqs(nodes) - const byClosingSeq = new Map<number, readonly string[]>() - let pending: string[] = [] - let seen = new Set<string>() - let turn: number | undefined - for (const node of nodes) { - if (node.kind === 'tool-result') { - if (node.isError) continue - for (const path of producedPaths(node.callView)) { - if (seen.has(path)) continue - seen.add(path) - pending.push(path) - } - continue - } - // Tool results carry no turn of their own, so the boundary is read off the - // nodes that do. A user message opens a turn without reporting a number, - // which is why the tracked turn goes back to undefined there: the next - // node to report one is stating the current turn, not entering a new one. - if (node.kind === 'user') { - turn = undefined - pending = [] - seen = new Set() - } else if ('turn' in node) { - if (turn !== undefined && node.turn !== turn) { - pending = [] - seen = new Set() - } - turn = node.turn - } - if (node.kind !== 'assistant' || !closing.has(node.seq)) continue - if (pending.length > 0) byClosingSeq.set(node.seq, pending) - pending = [] - seen = new Set() - } - return byClosingSeq -} - /** * Exact start time of the latest in-window turn without a matching end time. * @param turnTimings - In-window turn timings in event order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..1246433a33 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' @@ -46,6 +46,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * registration, and a domain upgrades by registering one row component. */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } + /** + * The chat view's turn-tail hole: rendered between a closing assistant + * message's body and its IconActions footer, once per turn (the render + * site elects the closing seq). Declared by the chat view entry; feature + * plugins (ui-deliverables' produced-files row) derive what they show + * from the owner currency, and an unregistered hole renders nothing — + * composing such a plugin out of cordis.yml turns its surface off. + */ + 'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps } /** * The composer takeover chain: entries are selector-routed replacements * of the default InputBar. Declared by this package's 'conversation' @@ -150,6 +159,24 @@ export interface ConvViewOwnerProps { onInspectDone?: () => void } +/** + * Owner currency of the chat view's turn-tail hole: the finalized snapshot + * and the closing assistant's anchor. Registrants derive their own facts + * from the nodes (the owner never pre-chews a feature's vocabulary), and + * open files through the same opener the tool rows use. + */ +export interface TurnTailOwnerProps { + /** Finalized snapshot nodes in surface order. */ + nodes: readonly ConversationNode[] + /** The closing assistant's seq — the anchor the tail renders under. */ + seq: number + /** + * Open a filesystem path through the Host (tool-row semantics; the chat + * view resolves relative paths against the session cwd). + */ + openFile: (path: string) => void +} + /** * Owner share of a per-view toolview slot: the call material the rendering * view supplies per row. Uniform across views — the trajectory/waterfall @@ -480,7 +507,7 @@ export interface ChatViewInjected { /** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */ export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'> + PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'> & PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'> /** diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index ac5f6574c8..725868d57a 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -17,7 +17,7 @@ export type { ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, - EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, + EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index b489bb29a1..9ba5ed3876 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -85,9 +85,6 @@ export const zh = { 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', - 'produced.label': '产物', - 'produced.more': '还有 {count} 个', - 'produced.open': '打开 {name}', 'message.branch': '在新对话中分支', 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', @@ -228,9 +225,6 @@ export const en = { 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', - 'produced.label': 'Produced', - 'produced.more': '{count} more', - 'produced.open': 'Open {name}', 'message.branch': 'Branch into a new conversation', 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index dc3e6f5241..4da138d1b5 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime, turnDeliverables } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' afterEach(() => { @@ -242,93 +242,6 @@ describe('chat-flow derivation', () => { expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) - it('turnDeliverables attributes each turn’s written files to the assistant that closes it', () => { - const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: `Write ${paths[0] ?? ''}`, - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - }, - }) - const produced = turnDeliverables([ - user(1, 'build it'), - assistant(2, 'writing', 1), - wrote(3, 'a', 'out/index.html'), - // Same file touched twice in one turn is one deliverable, in first-seen order. - wrote(4, 'b', 'out/app.css', 'out/index.html'), - // A read is not a deliverable; a failed write has no file to open. - { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, - { ...wrote(6, 'd', 'out/broken.html'), isError: true }, - assistant(7, 'done', 1), - user(8, 'again'), - assistant(9, 'second turn', 2), - ]) - expect(produced.get(7)).toEqual(['out/index.html', 'out/app.css']) - // A turn that produced nothing is absent, not an empty row. - expect(produced.has(9)).toBe(false) - // Nothing at all written: no entries. - expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) - }) - - it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => { - const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'str_replace_editor'), - // str_replace_editor's insert mutates behind a generic card, so the - // discriminant is the render intent, not the card shape alone. - callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, - }) - const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], - }, - }) - const produced = turnDeliverables([ - user(1, 'insert a line'), - inserted(2, 'i', 'notes.md'), - assistant(3, 'inserted', 1), - // Turn 2 mutates and then ends with no content text (interrupted, or its - // last text preceded the tool): its paths must not ride into turn 3. - user(4, 'now rewrite it'), - wrote(5, 'w', 'leaked.txt'), - user(6, 'and again'), - wrote(7, 'w2', 'notes.md'), - assistant(8, 'done', 3), - ]) - expect(produced.get(3)).toEqual(['notes.md']) - // Turn 3 lists only its own file — and `seen` did not suppress the rewrite - // of a path an earlier turn already touched. - expect(produced.get(8)).toEqual(['notes.md']) - expect([...produced.values()].flat()).not.toContain('leaked.txt') - }) - - it('renders the produced files under the closing message and opens one on click', () => { - const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - }, - }) - // Seven files: six chips plus an explicit remainder — the row bounds what - // it shows and says so rather than dropping the rest silently. - const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] - const h = makeHarness({ - nodes: [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)], - }) - const view = render(<h.ChatView {...h.props} />) - expect(view.getByText('产物')).toBeTruthy() - // Chips carry the basename; the full path stays reachable as the title. - const chip = view.getByRole('button', { name: '打开 deep/a.html' }) - expect(chip.textContent).toBe('a.html') - expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() - expect(view.getByText('还有 1 个')).toBeTruthy() - fireEvent.click(chip) - expect(h.openFile).toHaveBeenCalledWith('deep/a.html') - }) - it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { expect(runningTurnStartTime(new Map([ [1, { startTime: 1_000, endTime: 5_000 }], @@ -790,7 +703,9 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((_key: string, _owner: object) => { + h.props.renderSlot = ((key: string, _owner: object) => { + // The turnTail hole renders through the same share; only tool rows count here. + if (key !== 'conversation.chat.toolview') return null rowRenders += 1 return <div data-testid="counting-row" /> }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml new file mode 100644 index 0000000000..ee4c23c18c --- /dev/null +++ b/packages/client/ui-deliverables/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/client/ui-deliverables/README.md +README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043 +README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md new file mode 100644 index 0000000000..b8b0ea2ef1 --- /dev/null +++ b/packages/client/ui-deliverables/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-deliverables + +English | [中文](README.zh.md) + +Produced-files feature owner: registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole. All policy lives here; removing this plugin's line from cordis.yml removes the surface entirely, and the owning view renders an empty hole at zero cost. + +`producedForClosing` derives one turn's produced files from the tail hole's owner currency — the finalized snapshot nodes and the closing assistant's seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. + +`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). + +## Model Experience + +None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends provider requests. + +## Known Limitations and Deferred Work + +- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md new file mode 100644 index 0000000000..a16535b8a8 --- /dev/null +++ b/packages/client/ui-deliverables/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-deliverables + +[English](README.md) | 中文 + +产物文件的功能属主:把"完成的一轮以其产出文件收尾"的产物行注册进 chat 视图的 `conversation.chat.turnTail` 空位。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该交互面,属主视图以零成本渲染一个空的空位。 + +`producedForClosing` 从 tail 空位的 owner 通货——定稿的快照节点与收尾 assistant 的 seq——推导一轮产出的文件。词表是改写工具自身的跟随 `locations`,绝不是收尾正文:无论模型是否记得点名,产出文件都会被列出。改写按渲染意图识别而非工具名——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此新的改写工具靠声明自己做了什么加入。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。 + +`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 + +## 模型体验 + +无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json new file mode 100644 index 0000000000..0b5318499b --- /dev/null +++ b/packages/client/ui-deliverables/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-deliverables", + "description": "Produced-files turn tail: the deliverables row a finished turn ends with", + "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" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.module.css b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/Deliverables.module.css rename to packages/client/ui-deliverables/src/client/ProducedFiles.module.css diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx new file mode 100644 index 0000000000..609a688586 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -0,0 +1,61 @@ +// ProducedFiles: the produced-file row a finished turn ends with. The paths +// come from the mutation tools' follow-along locations (see +// producedForClosing), never from the closing prose, so the answer carries +// its own output whether or not the model remembered to name it. Clicking one +// goes through the same openFile the tool rows use — the Host's own opener, +// on the Host machine. + +import { useMemo } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { producedForClosing } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './ProducedFiles.module.css' + +/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ +const SHOWN = 6 + +/** Trailing path segment, the part that identifies the file at a glance. */ +function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** Full props: the turn-tail owner currency plus this plugin's locale seat. */ +export type ProducedFilesProps = TurnTailOwnerProps & PropsLocale<typeof NS> + +/** + * Render one turn's produced files as openable chips. + * @param props - the tail hole's owner currency (snapshot nodes, the closing + * assistant's seq, the chat view's file opener) and the locale seat. + * @returns The row, or `null` when the turn produced nothing. + */ +export function ProducedFiles({ nodes, seq, openFile, t }: ProducedFilesProps) { + // Per-closing-message derivation over the windowed snapshot: O(nodes) on + // node-identity change only, which is the same cadence the owning view + // re-derives its own flow at. + const paths = useMemo(() => producedForClosing(nodes, seq), [nodes, seq]) + if (paths.length === 0) return null + const shown = paths.slice(0, SHOWN) + const hidden = paths.length - shown.length + return ( + <div className={css.root}> + <span className={css.label}>{t('produced.label')}</span> + {shown.map(path => ( + <button + key={path} + type="button" + className={css.file} + // The full path is the disambiguator when two turns produce files + // that share a basename; the chip itself stays short. + title={path} + aria-label={t('produced.open', { name: path })} + onClick={() => { openFile(path) }} + > + {basename(path)} + </button> + ))} + {hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>} + </div> + ) +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts new file mode 100644 index 0000000000..536c019b01 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -0,0 +1,42 @@ +/** + * Deliverables plugin, browser half: registers the produced-files row into + * the chat view's turn-tail hole. All policy lives here — the derivation + * from the mutation tools' `locations`, the chip cap, and the copy — so + * composing this plugin out of cordis.yml removes the surface entirely; the + * owning view renders an empty hole at zero cost. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ProducedFiles } from './ProducedFiles.tsx' +import { en, NS, zh, type DeliverablesKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Produced-files row copy. */ + 'deliverables': DeliverablesKey + } +} + +export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' +export { producedForClosing } from './turn-deliverables.ts' + +/** Required services for the tail-slot registration and its dictionaries. */ +export const inject = ['slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the turn-tail entry. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') + ctx.slots.inject( + 'conversation.chat.turnTail', + () => ctx.slots.register({ + name: 'conversation.chat.turnTail', + id: 'produced-files', + order: 0, + locale: NS, + }, ProducedFiles), + ) +} diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts new file mode 100644 index 0000000000..aa51aa75a7 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -0,0 +1,21 @@ +/** `deliverables` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'deliverables' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'produced.label': '产物', + 'produced.more': '还有 {count} 个', + 'produced.open': '打开 {name}', +} + +/** English dictionary (same key set). */ +export const en: Record<DeliverablesKey, string> = { + 'produced.label': 'Produced', + 'produced.more': '{count} more', + 'produced.open': 'Open {name}', +} + +/** Union of this namespace's dictionary keys. */ +export type DeliverablesKey = keyof typeof zh diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts new file mode 100644 index 0000000000..faa0455b37 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -0,0 +1,78 @@ +/** + * Pure derivation of one turn's produced files from finalized snapshot + * nodes. Client-only and model-free: the vocabulary is the mutation tools' + * own follow-along `locations`, never the closing prose. + */ +import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Paths a call view reports having created or changed, by render intent rather + * than tool name: a diff card, or a generic card whose kind is `edit` (the + * shape `str_replace_editor`'s insert presents). Every other card produces + * nothing to open — a read looked, a delete removed, a terminal ran. + */ +function producedPaths(view: ToolResultNode['callView']): readonly string[] { + if (view === null) return [] + if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) + if (view.card === 'generic' && view.kind === 'edit') { + return (view.locations ?? []).map(location => location.path) + } + return [] +} + +/** + * Files produced by the turn the assistant at `seq` closes — the anchor the + * render site elects, so the row lands under the message that reports the + * work rather than after some mid-turn narration. + * + * The source is the mutation tools' own follow-along `locations`, not the + * closing prose: a produced file must be listed whether or not the model + * remembered to name it. A mutation is recognized by render intent, not by + * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape + * `str_replace_editor`'s insert presents) — so a new mutation tool joins by + * declaring what it does. Reads contribute nothing (looking at a file does not + * produce it), and neither do deletes (there is nothing left to open) or + * failed calls. Paths keep first-seen order and appear once, so a file written + * and then edited in the same turn is one entry. + * + * Accumulation resets on the turn boundary — a user message, or a node + * reporting a different turn number — so a turn that mutates files and then + * ends without content text cannot spill its paths into the next turn's row, + * nor leave the dedup set suppressing a file the next turn legitimately + * rewrites. Tool results carry no turn of their own; the boundary is read off + * the nodes that do, and a user message resets the tracked turn to undefined + * because the next node to report one is stating the current turn, not + * entering a new one. + * @param nodes - snapshot nodes (surface order). + * @param seq - the closing assistant's seq (the render site's anchor). + * @returns Produced paths in first-seen order; empty when the turn wrote nothing. + */ +export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] { + let pending: string[] = [] + let seen = new Set<string>() + let turn: number | undefined + for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.isError) continue + for (const path of producedPaths(node.callView)) { + if (seen.has(path)) continue + seen.add(path) + pending.push(path) + } + continue + } + if (node.kind === 'user') { + turn = undefined + pending = [] + seen = new Set() + } else if ('turn' in node) { + if (turn !== undefined && node.turn !== turn) { + pending = [] + seen = new Set() + } + turn = node.turn + } + if (node.kind === 'assistant' && node.seq === seq) return pending + } + return [] +} diff --git a/packages/client/ui-deliverables/src/css-modules.d.ts b/packages/client/ui-deliverables/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-deliverables/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts new file mode 100644 index 0000000000..012876cc2d --- /dev/null +++ b/packages/client/ui-deliverables/src/index.ts @@ -0,0 +1,9 @@ +/** + * Deliverables plugin, node half. Pure UI plugin: the empty apply exists so + * the plugin appears in the host cordis.yml / Loader; the browser half ships + * via exports["./client"], discovered through the package.json dshClient + * declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-deliverables/src/invariant.ts b/packages/client/ui-deliverables/src/invariant.ts new file mode 100644 index 0000000000..39c39591cf --- /dev/null +++ b/packages/client/ui-deliverables/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-deliverables`. + * @module @deepseek-ai/dsh-client-ui-deliverables/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-deliverables' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-deliverables-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: one slot registration and one dictionary + * registration, both effect-owned with disposal proven by the HMR-safety + * spec — the plugin emits no cordis events and owns no cross-plugin mutable + * state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx new file mode 100644 index 0000000000..e5d92424a3 --- /dev/null +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +/** + * ui-deliverables browser half: the derivation contract of + * `producedForClosing` over finalized snapshot nodes, the row's rendering + * and opener wiring, and the plugin registrations' fiber-teardown removal + * (HMR safety) against the real SlotsService. + */ +import { Context } from 'cordis' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { ProducedFiles } from '../src/client/ProducedFiles.tsx' +import { producedForClosing } from '../src/client/turn-deliverables.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import { apply as applyInvariant } from '../src/invariant.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const user = (seq: number, text: string): UserMessageNode => ({ + kind: 'user', + seq, + time: seq * 1000, + content: [{ type: 'text', text }] as never, + source: null, +}) +const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], +}) +const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId, + call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, + callTime: seq * 1_000 - 500, + content: [], isError: false, callView: null, resultView: null, +}) +const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: `Write ${paths[0] ?? ''}`, + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, +}) + +describe('producedForClosing derivation', () => { + it('attributes each turn’s written files to the assistant that closes it', () => { + const nodes: ConversationNode[] = [ + user(1, 'build it'), + assistant(2, 'writing', 1), + wrote(3, 'a', 'out/index.html'), + // Same file touched twice in one turn is one deliverable, in first-seen order. + wrote(4, 'b', 'out/app.css', 'out/index.html'), + // A read is not a deliverable; a failed write has no file to open. + { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, + { ...wrote(6, 'd', 'out/broken.html'), isError: true }, + assistant(7, 'done', 1), + user(8, 'again'), + assistant(9, 'second turn', 2), + ] + expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) + // A turn that produced nothing yields the empty list, and so does an + // anchor the window does not contain. + expect(producedForClosing(nodes, 9)).toEqual([]) + expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([]) + expect(producedForClosing(nodes, 999)).toEqual([]) + }) + + it('counts a generic edit and never spills across the turn boundary', () => { + const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'str_replace_editor'), + // str_replace_editor's insert mutates behind a generic card, so the + // discriminant is the render intent, not the card shape alone. + callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, + }) + const nodes: ConversationNode[] = [ + user(1, 'insert a line'), + inserted(2, 'i', 'notes.md'), + assistant(3, 'inserted', 1), + // Turn 2 mutates and then ends with no content text (interrupted, or its + // last text preceded the tool): its paths must not ride into turn 3. + user(4, 'now rewrite it'), + wrote(5, 'w', 'leaked.txt'), + user(6, 'and again'), + wrote(7, 'w2', 'notes.md'), + assistant(8, 'done', 3), + ] + expect(producedForClosing(nodes, 3)).toEqual(['notes.md']) + // Turn 3 lists only its own file — and the dedup set did not suppress the + // rewrite of a path an earlier turn already touched. + expect(producedForClosing(nodes, 8)).toEqual(['notes.md']) + expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt') + }) + + it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => { + const nodes: ConversationNode[] = [ + user(1, 'go'), + // A turnless surface node neither tracks nor resets the boundary. + { kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null }, + wrote(2, 'w', 'turn-one.txt'), + // A view-less result (window truncation) and cards without locations + // contribute nothing rather than crashing the walk. + toolResult(3, 'plain'), + { ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } }, + { ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } }, + assistant(6, 'mid narration', 1), + // Turn number advances with no user message in the window (truncated + // history): the accumulator must reset all the same. + assistant(7, 'closing', 2), + ] + expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt']) + expect(producedForClosing(nodes, 7)).toEqual([]) + }) +}) + +describe('ProducedFiles row', () => { + const t = makeTranslate(zh) + + it('renders capped chips with the full path reachable and opens one on click', () => { + // Seven files: six chips plus an explicit remainder — the row bounds what + // it shows and says so rather than dropping the rest silently. + const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] + const openFile = vi.fn<(path: string) => void>() + const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)] + const view = render(<ProducedFiles nodes={nodes} seq={3} openFile={openFile} t={t} />) + expect(view.getByText('产物')).toBeTruthy() + // Chips carry the basename; the full path stays reachable as the title. + const chip = view.getByRole('button', { name: '打开 deep/a.html' }) + expect(chip.textContent).toBe('a.html') + expect(chip.getAttribute('title')).toBe('deep/a.html') + expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() + expect(view.getByText('还有 1 个')).toBeTruthy() + fireEvent.click(chip) + expect(openFile).toHaveBeenCalledWith('deep/a.html') + }) + + it('a turn that produced nothing renders no row at all', () => { + const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)] + const view = render(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} t={t} />) + expect(view.container.firstChild).toBeNull() + }) +}) + +describe('package shells', () => { + it('the node half mounts inert and the invariant companion registers ownership', async () => { + // The node half is deliberately inert; mounting it must simply not throw. + applyNode() + const registered: string[] = [] + const ctx = new Context() + ctx.provide('invariants') + ctx.set('invariants', { + register: (pkg: string) => { registered.push(pkg); return () => {} }, + } as never) + const dispose = await applyInvariant(ctx) + expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-deliverables']) + expect(dispose).toBeTypeOf('function') + }) +}) + +describe('plugin registration', () => { + it('registers the tail entry and fiber disposal removes it', async () => { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + // The owning view's child declaration, stood up by a bench root entry. + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } }, + } as never, () => null) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1) + + await fiber.dispose() + expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json new file mode 100644 index 0000000000..3fa938986f --- /dev/null +++ b/packages/client/ui-deliverables/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-deliverables/tsdown.config.ts b/packages/client/ui-deliverables/tsdown.config.ts new file mode 100644 index 0000000000..ce1a8cefcc --- /dev/null +++ b/packages/client/ui-deliverables/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-deliverables', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 208e43aa5f..e50fa70b2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../../packages/client/ui-conversation + '@deepseek-ai/dsh-client-ui-deliverables': + specifier: workspace:^ + version: link:../../packages/client/ui-deliverables '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../packages/client/ui-goal @@ -1440,6 +1443,37 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-deliverables: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/ui-goal: devDependencies: '@deepseek-ai/dsh-client-connection': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 041972cb9f..cb7ffb7460 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ba9ba5d84..94e08da920 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -151,6 +151,7 @@ "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], + "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index e1d4088061..03a2b8bb59 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -56,6 +56,7 @@ { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-conversation" }, + { "path": "./packages/client/ui-deliverables" }, { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-slash" }, { "path": "./packages/client/ui-command" }, From 7aedc02ae32b1f049dfe8a5ca5a61f43a7748837 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:39:08 -0700 Subject: [PATCH 149/190] docs: regenerate module graph for ui-deliverables --- docs/module-graph.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/module-graph.md b/docs/module-graph.md index 3ad8ef0b7e..70f2570aa1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -159,6 +159,7 @@ flowchart TD pkg_client_test_runtime["client-test-runtime"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] + pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] @@ -840,6 +841,11 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1243,6 +1249,7 @@ flowchart TD | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`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) | From 5514dd2bd6409d076bfa963a1c835fdd97e3f61b Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 21:39:57 +0800 Subject: [PATCH 150/190] fix(llm-deepseek): refuse an API key no header can carry --- docs/config-catalog.md | 6 +++- packages/llm/llm-deepseek/src/index.ts | 27 +++++++++++++---- .../llm/llm-deepseek/tests/adapter.spec.ts | 30 +++++++++++++++++++ .../llm-deepseek/tests/dynamic-config.spec.ts | 21 ++++++++++++- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 89f1529387..a3e63d994d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -642,7 +642,11 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. Trimmed + * and format-checked by {@link resolveAdapterOptions}; a value no HTTP header can carry fails + * there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cd2bb9a24e..6aaab15573 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -13,7 +13,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -58,7 +58,11 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. Trimmed + * and format-checked by {@link resolveAdapterOptions}; a value no HTTP header can carry fails + * there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string @@ -174,8 +178,21 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + // An absent apiKey is not a failure: it falls through to apiKeyEnv below. + // A supplied one must be usable, so a malformed literal fails here beside + // the other beyond-schema bounds instead of inside `fetch`. + let apiKey: string | undefined + if (config.apiKey !== undefined) { + const checked = normalizeApiKey(config.apiKey) + if (!checked.ok) { + throw new Error(checked.reason === 'empty' + ? 'llm-deepseek: apiKey is empty; omit it to resolve the key from apiKeyEnv' + : 'llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only') + } + apiKey = checked.value + } return { - ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, + ...apiKey === undefined ? {} : { apiKey }, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { @@ -223,12 +240,12 @@ export function apply(ctx: Context, config: Config): void { const credentials = ctx.get('credentials') if (credentials !== undefined) { const hit = await credentials.resolve(ref) - if (hit !== undefined) return hit.value + if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) } else { // Without the seam, keep the historical ambient fallback so a plain // cordis.yml composition works from the environment alone. const ambient = process.env[ref] - if (ambient !== undefined && ambient.length > 0) return ambient + if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref) } throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9d104ace08..56ac3eb138 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -991,3 +991,33 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) }) + +describe('API key format', () => { + it('trims a padded literal apiKey', () => { + expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc') + }) + + it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => { + expect(resolveAdapterOptions({}).apiKey).toBeUndefined() + }) + + it('rejects a literal apiKey of whitespace only', () => { + expect(() => resolveAdapterOptions({ apiKey: ' ' })) + .toThrow(/apiKey is empty; omit it/) + }) + + it('rejects a literal apiKey no header can carry', () => { + expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' })) + .toThrow(/no HTTP header can carry/) + }) + + it('never echoes the key in the rejection', () => { + const secret = 'sk-\u{1F600}supersecret' + expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow() + try { + resolveAdapterOptions({ apiKey: secret }) + } catch (error) { + expect((error as Error).message).not.toContain('supersecret') + } + }) +}) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 11df9e1d81..e593e3a61d 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import LlmService from '@deepseek-ai/dsh-llm' +import LlmService, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -103,6 +103,25 @@ describe('request-level dynamic configuration', () => { expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') }) + it('rejects a stored credential no header can carry, never echoing it in the failure', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) + const secret = 'sk-\u{1F600}supersecret' + + // The real credentials seam (the path the web Models page writes through), + // not a hand-built stub: this package's own dynamic-config harness already + // boots one, and round-tripping the value through its actual store/read + // path is stronger evidence than a canned in-memory return would be. + await ctx.credentials.set(KEY_REF, secret) + const result = await prompt(ctx) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: INVALID_CREDENTIAL_CODE } }) + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.message).not.toContain(secret) + expect(result.finish.failure.message).not.toContain('supersecret') + expect(result.finish.failure.message).not.toContain('ByteString') + }) + it('advertises a live settings catalog without re-registration', async () => { const dir = await home() const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) From b1660ab8a447c66ae9c3356bc60640b5cab3e10e Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 21:40:06 +0800 Subject: [PATCH 151/190] docs(llm-deepseek): document the invalid-credential refusal --- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 3eb54a7a9f..6daba653f8 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 0cd265cadb2b2a619613761062ab2cef209bec83 -README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f +README.md: 51d5cf6a7049a2b1257ce3e9a284e777d3bdcdbc +README.zh.md: cc059a6011f7dc1ee0ab93dbd822de4b540b7e66 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 0cd265cadb..51d5cf6a70 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -53,7 +53,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every key is trimmed and format-checked before use — a literal `apiKey` at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1883b05427..cc059a6011 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -53,7 +53,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 From 45d78c92722155922a87e16a69d7bfe40d5c4eda Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 22:12:50 +0800 Subject: [PATCH 152/190] fix(llm-pi-ai): refuse an unusable API key before the header is built --- docs/config-catalog.md | 8 +++- packages/llm/llm-pi-ai/src/config.ts | 23 ++++++++-- packages/llm/llm-pi-ai/src/discovery.ts | 26 ++++++++++- packages/llm/llm-pi-ai/src/index.ts | 4 +- packages/llm/llm-pi-ai/tests/config.spec.ts | 24 ++++++++++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 45 ++++++++++++++++++- 6 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 packages/llm/llm-pi-ai/tests/config.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a3e63d994d..6c860cbd9d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -704,7 +704,11 @@ export interface Config { /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ + /** + * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its + * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a + * value no HTTP header can carry fails there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string @@ -776,7 +780,7 @@ export interface PiAiModelProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:122`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7473dbb7ae..7e8374ab9f 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -19,7 +19,7 @@ import z from 'schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { resolveRouteModels } from './catalog.ts' import type { PiAiModelProfile } from './catalog.ts' @@ -38,7 +38,11 @@ export type { PiAiModelProfile } from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ + /** + * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its + * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a + * value no HTTP header can carry fails there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string @@ -220,8 +224,18 @@ export function resolveProfiles( for (const [provider, source] of entries) { rejectRemovedFields(provider, source) if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { - throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) + // Omission selects the installed provider's own auth — ambient discovery + // or OAuth — so only a supplied key is judged. + let apiKey: string | undefined + if (source.apiKey !== undefined) { + const checked = normalizeApiKey(source.apiKey) + if (!checked.ok) { + throw new Error(checked.reason === 'empty' + ? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication` + : `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;` + + ' paste the raw key only') + } + apiKey = checked.value } if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) @@ -252,6 +266,7 @@ export function resolveProfiles( const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { ...rest, + ...apiKey === undefined ? {} : { apiKey }, provider, displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index bff2c9a7ca..014c9c2f3e 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -22,7 +22,7 @@ * @module dsh-llm-pi-ai/discovery */ -import { LlmError } from '@deepseek-ai/dsh-llm' +import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm' import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm' import { attributionHeaders } from '@deepseek-ai/dsh-llm' import { catalogModels } from './catalog.ts' @@ -161,6 +161,25 @@ function readListing(body: unknown): LlmDiscoveredModel[] { return models } +/** + * Accept one probe key, or refuse it before the header is built. Without this + * the `fetch` below would throw a ByteString `TypeError` that this function's + * catch reports as `could not reach <url>` — blaming the network for a local, + * deterministic fault. + * @param raw - the key typed into the form or read from storage. + * @returns the trimmed, usable key. + */ +function usableProbeKey(raw: string): string { + const checked = normalizeApiKey(raw) + if (checked.ok) return checked.value + throw new LlmError( + checked.reason === 'empty' + ? 'this provider\'s API key is blank; enter it on the Models page, or clear it to probe unauthenticated' + : 'this provider\'s API key contains characters no HTTP header can carry; paste the raw key only', + INVALID_CREDENTIAL_CODE, + ) +} + /** * Interrogate one draft provider endpoint for the models it advertises. * @param request - the endpoint, protocol, and one-shot credential to use. @@ -216,7 +235,10 @@ export async function discoverModels( // stored one is only asked for here, past the catalog short-circuit and the // protocol check, so a route answered from the registry costs no credential // lookup — and no diagnostic about a credential it never needed. - const apiKey = request.apiKey ?? await storedApiKey?.() + // A probe carrying no key stays unauthenticated, which is how a route that + // relies on the provider's own ambient discovery is meant to be asked. + const supplied = request.apiKey ?? await storedApiKey?.() + const apiKey = supplied === undefined ? undefined : usableProbeKey(supplied) let response: Response try { response = await fetch(url, { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 0d058e94ac..c30fd3db6f 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -43,7 +43,7 @@ */ import type { Context } from 'cordis' -import { LlmError } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' @@ -145,7 +145,7 @@ export function apply(ctx: Context, config: Config): void { // Without the seam, read exactly the named variable so a plain // cordis.yml composition works from the environment alone. : process.env[ref] - if (hit !== undefined && hit.length > 0) return hit + if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref) throw new LlmError( `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` + ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,` diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts new file mode 100644 index 0000000000..90f8487ad8 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { resolveProfiles } from '../src/config.ts' + +describe('API key format', () => { + it('trims a padded literal apiKey into the resolved profile', () => { + const resolved = resolveProfiles({ openai: { apiKey: ' sk-abc ', baseURL: 'https://acme.test' } }) + expect(resolved.get('openai')?.apiKey).toBe('sk-abc') + }) + + it('keeps an omitted apiKey absent so ambient authentication still applies', () => { + const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } }) + expect(resolved.get('openai')?.apiKey).toBeUndefined() + }) + + it('still tells an empty apiKey to omit itself', () => { + expect(() => resolveProfiles({ openai: { apiKey: ' ', baseURL: 'https://acme.test' } })) + .toThrow(/omit it to use ambient authentication/) + }) + + it('rejects an apiKey no header can carry', () => { + expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } })) + .toThrow(/no HTTP header can carry/) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 916700fbbf..63b43ecdab 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -1,6 +1,6 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' @@ -12,6 +12,9 @@ const servers: Server[] = [] const touchedEnv: string[] = [] afterEach(async () => { + // A no-op when the test never stubbed `fetch`; only 'probe key format' + // below installs one. + vi.unstubAllGlobals() for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name) await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) }) @@ -311,3 +314,43 @@ describe('draft-provider model discovery', () => { .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) }) }) + +describe('probe key format', () => { + it('reports an illegal probe key as a credential fault, not an unreachable endpoint', async () => { + await expect(discoverModels({ + baseURL: 'https://acme.test', + api: 'openai-completions', + apiKey: 'sk-\u{1F600}', + })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + }) + + it('reports a blank probe key as a credential fault too', async () => { + // A cleared form field arrives as '', not an absent key; it must fail the + // same way a typed-in illegal key does, rather than probing unauthenticated. + await expect(discoverModels({ + baseURL: 'https://acme.test', + api: 'openai-completions', + apiKey: '', + })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + }) + + it('leaves a probe with no key unauthenticated', async () => { + // The file's other cases capture headers through a real local HTTP server + // (`listingServer`); this one has no route or stored key to resolve, so + // the smallest real double is a `fetch` stub, scoped to this test and + // unstubbed by the shared `afterEach` above. + const requests: RequestInit[] = [] + vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => { + requests.push(init ?? {}) + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + + await discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions' }) + + const headers = new Headers(requests[0]?.headers) + expect(headers.has('authorization')).toBe(false) + }) +}) From 665b5697bb46d87b85c832dec2689685f37edbd7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 22:13:02 +0800 Subject: [PATCH 153/190] docs(llm-pi-ai): document the invalid-credential refusal --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 4 ++-- packages/llm/llm-pi-ai/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index b4e9cffabb..bd322be07f 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: af0e952dd8dbd9767b98229ee6b87262007d6738 -README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c +README.md: 0dcf15d6caf365a1f8e75088cb363eaa6560a6ec +README.zh.md: 79be5d320c0f4411f7cf8a0bd72c887048929dcb diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index af0e952dd8..0dcf15d6ca 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. @@ -85,7 +85,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. -A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. +A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint. Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f8a19999f0..79be5d320c 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 @@ -85,7 +85,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 -草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。 +草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。 询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 From cf9eade39d5019ccbbd90e0f1a969274074ea691 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 22:30:43 +0800 Subject: [PATCH 154/190] feat(web): refuse an unusable API key on the field that holds it --- .../src/client/CustomProviderCard.tsx | 14 ++- .../ui-models/src/client/ProviderEditor.tsx | 19 +++- .../client/ui-models/src/client/apiKey.ts | 50 +++++++++ .../client/ui-models/src/client/locales.ts | 6 ++ .../ui-models/tests/components.spec.tsx | 50 +++++++++ .../ui-models/tests/provider-form.spec.tsx | 102 ++++++++++++++++++ 6 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 packages/client/ui-models/src/client/apiKey.ts diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b4c655472a..a252d99586 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -18,6 +18,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' @@ -80,8 +81,14 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // bad row is named by its position here too. Capacities have route-level // fallbacks; what a route cannot default is at least one model. const modelFailure = validateDeepSeekModels(models) + const keyFailure = apiKeyFailure(keyDraft) + // The typed key with paste whitespace removed. A blank field yields an empty + // string, which the create path reads as "no key supplied" — a route may + // legitimately authenticate through the provider's own ambient discovery. + const keyValue = keyDraft.trim() const ready = route.length > 0 && !routeInvalid && !routeTaken && baseURL.length > 0 && models.length > 0 && modelFailure === undefined + && keyFailure === undefined // The one blocked gate worth a line under the form. The route id is omitted // because its own field already explains itself, and a satisfied card says // nothing at all rather than printing an empty paragraph. @@ -112,8 +119,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { expectedRevision: openedAt, }) if (!response.result.ok) return response.result.error.message - if (keyDraft.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + if (keyValue.length > 0) { + const stored = await api.credentials.set({ ref: keyRef, value: keyValue }) // The profile landed; saying the key did not is the only honest report, // and the row is now editable so the key can be entered again there. if (!stored.result.ok) return stored.result.error.message @@ -208,6 +215,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { disabled={disabled} onChange={(event) => { setKeyDraft(event.target.value) }} /> + {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>} </div> <ModelListEditor models={models} @@ -216,7 +224,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { settingsNs: NS, baseURL, api: protocol, - ...keyDraft.length === 0 ? {} : { apiKey: keyDraft }, + ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} api={api} t={t} diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index f48572cc58..f33791eab5 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -22,6 +22,7 @@ import { import { DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, } from './DeepSeekModelsEditor.tsx' +import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import { deriveKeyRef, messageOf } from './store.ts' @@ -163,7 +164,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const stringAt = (source: unknown, key: string): string | undefined => { const value = getPath(source, [key]) - return typeof value === 'string' && value.length > 0 ? value : undefined + return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next)) @@ -172,6 +173,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // The model list is validated by the same per-row checker for both families, // so a bad row is named by its position rather than by a blanket message. const modelFailure = validateDeepSeekModels(getPath(draft, ['models'])) + const keyFailure = apiKeyFailure(keyDraft) + // What a probe or a write must carry: the typed key with paste whitespace + // removed. A blank field yields an empty string, which both call sites read + // as "no key supplied" rather than as a key — that is how a card whose + // provider already has a stored key is edited without re-entering it. + const keyValue = keyDraft.trim() // What the form currently shows, which is what an interrogation must ask: // an edited-but-unsaved endpoint, and a key typed but not yet stored. const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api') @@ -183,7 +190,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { provider: props.provider, ...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL }, ...probeApi === undefined ? {} : { api: probeApi }, - ...keyDraft.length === 0 ? {} : { apiKey: keyDraft }, + ...keyValue.length === 0 ? {} : { apiKey: keyValue }, } /** * The write for this card, or a failure message. Every edit travels as @@ -226,8 +233,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { : response.result.error.message } } - if (keyDraft.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + if (keyValue.length > 0) { + const stored = await api.credentials.set({ ref: keyRef, value: keyValue }) if (!stored.result.ok) return stored.result.error.message } setKeyDraft('') @@ -313,6 +320,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { disabled={disabled || keyLocked} onChange={(event) => { setKeyDraft(event.target.value) }} /> + {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>} </div> <details className={styles['customized']}> <summary className={styles['customizedSummary']}>{t('customized')}</summary> @@ -396,7 +404,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { <EditorFooter t={t} busy={busy} - submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined} + submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined + || keyFailure !== undefined} submitLabel="apply" submitBusyLabel="applying" onCancel={() => { props.onClose(false) }} diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts new file mode 100644 index 0000000000..a9d5bb3d32 --- /dev/null +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -0,0 +1,50 @@ +/** + * Browser-side judgement of a typed API key. + * @module @deepseek-ai/dsh-client-ui-models/apiKey + */ + +/** + * Twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`: printable ASCII, space + * excluded. Client packages reference only client packages, so the charset + * rule is mirrored here rather than imported; keep the two in step, as + * `validateDeepSeekModels` is kept in step with the host's `catalogModel`. + */ +const LEGAL_API_KEY = /^[\x21-\x7E]+$/ + +/** + * A pasted `NAME=value` environment line. Restricted to an upper-case + * identifier so a real key cannot match: `sk-` forms break at the hyphen. + * This heuristic runs only here — a resolver applying it could lock a user + * out of a gateway whose key legitimately takes this shape, with the + * environment refusing it too and no way through. + */ +const ENV_LINE = /^[A-Z][A-Z0-9_]*=/ + +/** Copy key naming why a typed key cannot be saved. */ +export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' + +/** Whether a value is wrapped in one matching pair of quotes. */ +function isQuoted(value: string): boolean { + const first = value[0] + if (first !== '"' && first !== '\'' && first !== '`') return false + return value.length > 1 && value.endsWith(first) +} + +/** + * Judge the key input's current value. + * + * An empty field is not a failure: every card opens with it empty even when a + * key is already stored, where it means keep that one. A field holding only + * whitespace is a failure rather than an empty field, so typed input is never + * silently discarded. + * @param draft - the key input's current value, untrimmed. + * @returns the copy key for a field-level failure, or `undefined` to allow submit. + */ +export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined { + if (draft.length === 0) return undefined + const value = draft.trim() + if (value.length === 0) return 'keyBlank' + if (ENV_LINE.test(value) || isQuoted(value)) return 'keyLooksWrapped' + if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters' + return undefined +} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 19463d98fa..fbfc85c7f1 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -46,6 +46,9 @@ export const en = { addModel: 'Add model', removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', + keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', + keyIllegalCharacters: 'This API key contains characters that cannot be sent. Paste the raw key only.', + keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', modelNameInvalid: 'Display name cannot be empty.', @@ -130,6 +133,9 @@ export const zh: typeof en = { addModel: '添加模型', removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', + keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', + keyIllegalCharacters: '该 API 密钥含有无法发送的字符。请只粘贴原始密钥。', + keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index aa9082e7dd..d9034ecd44 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -11,6 +11,7 @@ import { pathOps } from '../src/client/ProviderEditor.tsx' import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' +import { apiKeyFailure } from '../src/client/apiKey.ts' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -1080,3 +1081,52 @@ describe('ModelsSection', () => { expect(failure).toBe('connection lost') }) }) + +describe('apiKeyFailure', () => { + it('treats a blank field as no failure — it means keep the stored key', () => { + expect(apiKeyFailure('')).toBeUndefined() + }) + + it.each([ + ['a printable-ASCII key', 'sk-0123456789'], + ['a padded key, which the caller trims', ' sk-abc '], + ['the printable-ASCII boundary characters', '!~'], + ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'], + ])('accepts %s', (_label, draft) => { + expect(apiKeyFailure(draft)).toBeUndefined() + }) + + it.each([ + ['spaces', ' '], + ['a tab', '\t'], + ])('fails a field holding only %s instead of silently dropping it', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyBlank') + }) + + it.each([ + ['an emoji', 'sk-\u{1F600}'], + ['CJK text', 'sk-你好'], + ['full-width punctuation', 'sk-abc,'], + ['an interior space', 'sk-abc def'], + ['a C0 control character', 'sk-abc\x01'], + ['a latin-1 character', 'sk-café'], + ])('fails %s as illegal characters', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters') + }) + + it.each([ + ['a pasted environment line', 'DEEPSEEK_API_KEY=sk-abc'], + ['double quotes', '"sk-abc"'], + ['single quotes', '\'sk-abc\''], + ['backticks', '`sk-abc`'], + ])('fails %s as wrapped', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyLooksWrapped') + }) + + it('needs a matching closing quote before it calls a value wrapped', () => { + // A lone quote and an unbalanced one are legal printable ASCII, so the + // heuristic leaves them alone rather than guessing at a paste error. + expect(apiKeyFailure('"')).toBeUndefined() + expect(apiKeyFailure('"a')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..a167710153 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -862,4 +862,106 @@ describe('hand-declared providers', () => { await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() }) expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() }) + + it('refuses an unusable key on the field and blocks creation', () => { + const { mutate, set } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // A hand-declared route reaches the same judgement as an edited one, so a + // key that no header can carry never becomes a profile plus a bad secret. + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(buttonNamed(en.create).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('creates without a key when the route authenticates some other way', async () => { + const { set, onClose } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'ambient-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.click(screen.getByText(en.create)) + + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(set).not.toHaveBeenCalled() + }) +}) + +describe('API key field', () => { + it('submits with a blank key field without writing a credential', async () => { + const { mutate, set } = await mountSection() + openEditor('openai') + + // The field opens empty even for a provider whose key is stored, where it + // means "keep that one" — so editing anything else must not require it. + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://moved.example/v1' } }) + expect(buttonNamed(en.apply).disabled).toBe(false) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalled() }) + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit and names the field when the key holds only whitespace', async () => { + const { mutate, set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } }) + + expect(screen.getByText(en.keyBlank)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit when the key contains characters no header can carry', async () => { + const { set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit when a whole NAME=value line was pasted', async () => { + await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } }) + + expect(screen.getByText(en.keyLooksWrapped)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + }) + + it('trims a padded key before storing it', async () => { + const { set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } }) + expect(buttonNamed(en.apply).disabled).toBe(false) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(set).toHaveBeenCalled() }) + expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc') + }) + + it('carries the trimmed key into an interrogation, not the padded draft', async () => { + const { discover } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } }) + fireEvent.click(screen.getByRole('button', { name: en.fetchModels })) + + await waitFor(() => { expect(discover).toHaveBeenCalled() }) + expect(firstProbe(discover)).toMatchObject({ apiKey: 'sk-abc' }) + }) }) From a89c26b6110420ff59582528839d01230c970e9a Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 22:42:56 +0800 Subject: [PATCH 155/190] test(web): pin the API key field refusal end to end --- ...-08-06-api-key-format-validation.i18n.yaml | 6 + .../2026-08-06-api-key-format-validation.md | 105 ++++++++++++++++++ ...2026-08-06-api-key-format-validation.zh.md | 105 ++++++++++++++++++ ...-08-06-api-key-format-validation.i18n.yaml | 6 - .../2026-08-06-api-key-format-validation.md | 101 ----------------- ...2026-08-06-api-key-format-validation.zh.md | 101 ----------------- apps/web/tests/models-settings.e2e.ts | 19 ++++ 7 files changed, 235 insertions(+), 208 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml new file mode 100644 index 0000000000..42b42a591a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +2026-08-06-api-key-format-validation.md: 9ec247cb2ba2578158759ec1115c5d3a95778cc4 +2026-08-06-api-key-format-validation.zh.md: 63c6a8c17ee93b4b68eb3505d5499756e9fb2401 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md new file mode 100644 index 0000000000..9ec247cb2b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -0,0 +1,105 @@ +# Agent Note: Validate API key format before it reaches an HTTP header + +Status: implemented + +English | [中文](2026-08-06-api-key-format-validation.zh.md) + +## Problem + +An API key holding characters no HTTP header value can carry was accepted by every configuration surface and failed only when a request was built, far from the field that caused it. + +Pasting a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page reported a successful save. The first turn then failed with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produced this because `fetch` builds the `Bearer` header inside the `try` in [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault was also retried three times. + +`llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach <url>`, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored. + +Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. + +Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. + +## Decision + +One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. + +This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. + +A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. + +### Invariants belong at every layer; heuristics belong where the human is + +The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. + +The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. + +### Absence is a configuration state, not a missing key + +"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. + +**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. + +**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key. + +**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already worded it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. + +`normalizeApiKey` therefore takes `string`, never `string | undefined`. + +### Where the rule lives + +`normalizeApiKey` is a module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). + +Both adapters also need the identical "refuse a stored credential" diagnosis, differing only by package prefix. `LlmError` is declared in the seam's `index.ts`, so `assertUsableApiKey(raw, pkg, ref)` lives there beside it and neither adapter carries a local copy. The predicate module stays dependency-free: importing `LlmError` into `api-key.ts` would cycle with `index.ts`'s re-export of it. + +The client cannot import any of this: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate in its own `apiKey.ts` and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema. Each side names the other in a comment. + +### What each surface does + +| Surface | Behavior | +|---|---| +| `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. | +| `llm-deepseek` `resolveAdapterOptions` | Normalizes a present `apiKey`, throwing beside the other beyond-schema bounds; uses the trimmed value. An absent one falls through to `apiKeyEnv`. | +| `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. | +| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | +| `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | +| `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | +| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit is gated and the failure renders on the field, matching the existing `modelFailure` pattern. | + +`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. `CustomProviderCard` carries the same judgement for a hand-declared route. + +`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stands as it was. + +## Alternatives considered + +**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. + +**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. + +**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for. + +**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose request header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. + +**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It lost because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. + +**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. + +**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. + +## Consequences + +A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint. + +The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. + +Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. + +The charset predicate exists twice, once per source plane. The layout forbids sharing it; each side carries its own test and names its twin. + +Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value fails at resolution rather than at request time. The diagnosis improves, but the failure moves earlier for anyone currently holding one. + +The costliest way to get this wrong would have been to treat absence as invalidity: a rule applied to `undefined` breaks every route authenticating through ambient discovery or OAuth, and a blank field that blocked submit makes editing any other setting demand re-entering the key. Both are pinned by tests rather than left to care. + +## Testing + +`packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key. + +`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. + +`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md new file mode 100644 index 0000000000..63c6a8c17e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -0,0 +1,105 @@ +# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 + +Status: implemented + +[English](2026-08-06-api-key-format-validation.md) | 中文 + +## Problem + +一个含有 HTTP header value 无法承载的字符的 API Key,曾被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 + +把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 + +同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 + +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 + +来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 + +## Decision + +一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 + +这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 + +第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 + +### 不变量属于每一层,启发式属于人所在的那一层 + +字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 + +形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 + +### 「没有 Key」是一种配置状态,不是缺失 + +在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 + +**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 + +**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。 + +**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面所采用的。 + +因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 + +### 规则住在哪里 + +`normalizeApiKey` 是 `dsh-llm` seam 的一个模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 + +两个适配器同样都需要那句完全相同的「拒绝一个已存储凭据」的诊断,差别仅在包名前缀。`LlmError` 声明在 seam 的 `index.ts` 中,因此 `assertUsableApiKey(raw, pkg, ref)` 就住在它旁边,两个适配器都不再各留一份。断言模块本身保持零依赖:把 `LlmError` 引入 `api-key.ts` 会与 `index.ts` 对它的再导出成环。 + +客户端无法引入其中任何一个:client 包只 reference client 包,因此 `packages/client/ui-models` 在自己的 `apiKey.ts` 中镜像这个断言并持有本地化文案,正如 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 + +### 各个界面各做什么 + +| 界面 | 行为 | +|---|---| +| `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 | +| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与其他超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 回落到 `apiKeyEnv`。 | +| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | +| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | +| `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | +| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交受拦截,失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | + +`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。 + +`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 + +## Alternatives considered + +**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 + +**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 + +**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。 + +**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的请求 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 + +**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 + +**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 + +**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 + +## Consequences + +格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。 + +形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 + +限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 + +字符集断言存在两份,每个 source plane 一份。布局禁止共享它;两侧各自带测试并在注释中指名其孪生体。 + +早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。诊断变好了,但对当前正持有这类值的人而言,失败点提前了。 + +把这件事做错的最大代价,会是把「未指定」当成「非法」:一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由,而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都由测试钉住,而不是仅仰赖谨慎。 + +## Testing + +`packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。 + +`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 + +`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 拦截提交、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml deleted file mode 100644 index f62a18e0eb..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.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/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: dc19baa8b697998df2892f0840a35a8232cc92de -2026-08-06-api-key-format-validation.zh.md: 28073660b1d4868fecf5ce419726d6d997383392 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md deleted file mode 100644 index dc19baa8b6..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent Note: Validate API key format before it reaches an HTTP header - -Status: proposed - -English | [中文](2026-08-06-api-key-format-validation.zh.md) - -## Problem - -An API key holding characters no HTTP header value can carry is accepted by every configuration surface and fails only when a request is built, far from the field that caused it. - -Paste a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page and the save reports success. The first turn then fails with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produces this because `fetch` builds the `Bearer` header inside the `try` at [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault is also retried three times. - -`llm-pi-ai` is worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wraps every failure as `could not reach <url>`, so a local key fault is reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sends an illegal key before anything is stored. - -Whitespace passes every check. `ProviderEditor` tests `keyDraft.length` and `resolveAdapterOptions` tests `config.apiKey.length`, so a key of three spaces stores and then authenticates as `Bearer` plus blanks. `llm-pi-ai` rejects an empty literal `apiKey` in `resolveProfiles`, but applies no check whatsoever to a credential- or environment-sourced key — which is the path the Models page writes, and therefore the path users actually take. - -Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. - -## Proposal - -One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. - -This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. - -A second, narrower rule catches a pasted environment line: reject input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. - -### Invariants belong at every layer; heuristics belong where the human is - -The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. - -The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. - -### Absence is a configuration state, not a missing key - -"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. - -**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` exists to carry this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. - -**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` already skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field must never block submit, or editing a base URL would demand re-entering the key. - -**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already words it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. - -`normalizeApiKey` therefore takes `string`, never `string | undefined`. - -### Where the rule lives - -`normalizeApiKey` is a new module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). - -The client cannot import it: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema today. Each side names the other in a comment. - -### What each surface does - -| Surface | Change | -|---|---| -| `dsh-llm` | Add `normalizeApiKey`; add `INVALID_CREDENTIAL`, deliberately outside `DEFAULT_RETRYABLE_CODES`. | -| `llm-deepseek` `resolveAdapterOptions` | Normalize a present `apiKey`, throwing beside the existing beyond-schema bounds; use the trimmed value. An absent one still falls through to `apiKeyEnv`. Closes dsh-external#210. | -| `llm-deepseek` `resolveApiKey` | Normalize what the credentials seam or environment returns; reject with `INVALID_CREDENTIAL` naming the Models page, never echoing the key. | -| `llm-pi-ai` `resolveProfiles` | Widen the existing emptiness check to the shared rule, keeping its "omit it to use ambient authentication" wording. | -| `llm-pi-ai` `resolveApiKey` | Normalize the credential and environment paths, which are unchecked today. A profile naming no credential still returns `undefined` untouched, so ambient and OAuth routes are unaffected. | -| `llm-pi-ai` `discoverModels` | Normalize before building the header, so an illegal key stops reporting as an unreachable endpoint. A probe carrying no key stays unauthenticated as it is today. | -| `ui-models` | Mirror the charset rule, add the shape heuristic, trim `keyDraft` before probe and `credentials.set`, and fix the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure, so typed input is never silently discarded. Gate submit and show the failure on the field, matching the existing `modelFailure` pattern. | - -`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. - -`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stays as it is. - -## Alternatives considered - -**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It loses because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. - -**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. - -**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. - -**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It loses because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. - -**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. - -**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on today's code, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verifies nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this note makes reliable; building it first would produce a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call at save time would be an unexpected behavior rather than a missing one. - -## Acceptance criteria - -- The browser, both resolvers, and both credential reads accept and reject the same *provided* strings: whitespace-only, padded, interior-space, C0 control, emoji, CJK, and full-width inputs are refused; a printable-ASCII key is accepted, trimmed. -- A profile naming no credential still resolves to no key, and a route authenticating through the installed provider's own ambient discovery or OAuth keeps working untouched. -- A blank key field saves the rest of the card without writing a credential; a field holding only whitespace fails on the field instead of being silently dropped. -- A rejected key names the API key field in the web UI and blocks submit; nothing is written to settings or credentials. -- A key that reaches a resolver illegally fails as `INVALID_CREDENTIAL` with a message naming where to fix it, containing no part of the key, and is not retried. -- `llm-pi-ai` discovery reports an illegal key as a key fault, not as an unreachable endpoint. -- A legal key still travels the existing `credentials.set` path unchanged. - -## Risks - -The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. - -Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. - -The charset predicate exists twice, once per source plane. The layout forbids sharing it, and the duplication gate may flag the pair; each side carries its own test and names its twin. - -The costliest way to get this wrong is to treat absence as invalidity. A rule applied to `undefined` would break every route authenticating through ambient discovery or OAuth — `openai-codex` cannot take a key at all — and a blank field that blocked submit would make editing any other setting demand re-entering the key. Both belong in the tests, not only in this note. - -Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value begins failing at resolution rather than at request time. That is the intent — the diagnosis improves — but it moves the failure earlier for anyone currently holding one. diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md deleted file mode 100644 index 28073660b1..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 - -Status: proposed - -[English](2026-08-06-api-key-format-validation.md) | 中文 - -## Problem - -一个含有 HTTP header value 无法承载的字符的 API Key,会被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 - -把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 - -同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 - -空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 - -来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 - -## Proposal - -一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 - -这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 - -第二条更窄的规则用于识别整行粘贴的环境变量:拒绝匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 - -### 不变量属于每一层,启发式属于人所在的那一层 - -字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 - -形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 - -### 「没有 Key」是一种配置状态,不是缺失 - -在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 - -**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 的存在就是为了承载这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 - -**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时本就完全跳过 `credentials.set`,这一点保持不变:留空绝不能拦截提交,否则改一个 base URL 都得重新输一遍 Key。 - -**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面要采用的。 - -因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 - -### 规则住在哪里 - -`normalizeApiKey` 是 `dsh-llm` seam 的新模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 - -客户端无法引入它:client 包只 reference client 包,因此 `packages/client/ui-models` 镜像这个断言并持有本地化文案,正如今天 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 - -### 各个界面各做什么 - -| 界面 | 改动 | -|---|---| -| `dsh-llm` | 新增 `normalizeApiKey`;新增 `INVALID_CREDENTIAL`,刻意不进 `DEFAULT_RETRYABLE_CODES`。 | -| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与既有的超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 仍照旧回落到 `apiKeyEnv`。关闭 dsh-external#210。 | -| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值;以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | -| `llm-pi-ai` `resolveProfiles` | 把既有的空值检查扩展为这条共享规则,并保留其「omit it to use ambient authentication」的措辞。 | -| `llm-pi-ai` `resolveApiKey` | 归一化今天完全未受检的凭据与环境路径。不指定任何凭据的 profile 仍原样返回 `undefined`,ambient 与 OAuth 路由不受影响。 | -| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 不再被报成端点不可达。不带 Key 的探测照旧保持未鉴权。 | -| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则以字段级失败呈现,使已输入的内容绝不被静默丢弃。按既有 `modelFailure` 的模式拦截提交并在字段上呈现失败。 | - -`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。 - -`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 - -## Alternatives considered - -**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 - -**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 - -**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 - -**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 - -**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 - -**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在今天的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本 Agent Note 要让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 - -## Acceptance criteria - -- 浏览器、两个 resolver 与两处凭据读取接受与拒绝同一组**已提供**的字符串:纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角输入均被拒绝;可打印 ASCII 的 Key 被接受并 trim。 -- 不指定任何凭据的 profile 仍解析为「没有 Key」,通过内置 provider 自身的 ambient 发现或 OAuth 鉴权的路由原样可用。 -- 留空的 Key 输入框可以保存卡片其余部分而不写入凭据;只含空白的输入框则以字段级失败呈现,而不是被静默丢弃。 -- 被拒绝的 Key 在 Web UI 中定位到 API Key 字段并拦截提交;settings 与凭据均不写入。 -- 非法抵达 resolver 的 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置、不含 Key 的任何片段,且不被重试。 -- `llm-pi-ai` 的探测把非法 Key 报为 Key 故障,而非端点不可达。 -- 合法 Key 仍沿既有 `credentials.set` 路径原样通过。 - -## Risks - -形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 - -限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 - -字符集断言存在两份,每个 source plane 一份。布局禁止共享它,重复检测门禁可能会标记这一对;两侧各自带测试并在注释中指名其孪生体。 - -把这件事做错的最大代价,是把「未指定」当成「非法」。一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由——`openai-codex` 根本无法接受 Key——而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都应落在测试里,而不只是写在本 Agent Note 中。 - -早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。这正是意图所在——诊断变好了——但对当前正持有这类值的人而言,失败点提前了。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1d9117dc85..0468e0e9d1 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -75,6 +75,25 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE) }, 60_000) + it('refuses a key no HTTP header can carry before anything is written', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key')) + const dialog = page.getByRole('dialog', { name: '设置' }) + const key = dialog.getByLabel('API 密钥') + const save = dialog.getByRole('button', { name: '保存', exact: true }) + + // The paste that used to save cleanly and then fail the first turn with a + // ByteString TypeError now names the field that holds it. + await key.fill('sk-\u{1F600}minimax') + await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').waitFor({ timeout: 10_000 }) + await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) + + // Clearing it restores submit: an empty field means "keep what is stored", + // never a refusal, or editing any other setting would demand the key. + await key.fill('') + await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) + expect(await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').count()).toBe(0) + }, 60_000) + it('stores the key under the derived reference and the route registers live', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) From 6b75bb0425bad75fdaa9cb7a1be932ee8276b758 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Thu, 6 Aug 2026 23:15:08 +0800 Subject: [PATCH 156/190] fix(web): say the API key format is wrong rather than naming the characters --- apps/web/tests/models-settings.e2e.ts | 4 ++-- packages/client/ui-models/src/client/locales.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 0468e0e9d1..e5e7d09c5f 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -84,14 +84,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The paste that used to save cleanly and then fail the first turn with a // ByteString TypeError now names the field that holds it. await key.fill('sk-\u{1F600}minimax') - await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').waitFor({ timeout: 10_000 }) + await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 }) await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) // Clearing it restores submit: an empty field means "keep what is stored", // never a refusal, or editing any other setting would demand the key. await key.fill('') await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) - expect(await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').count()).toBe(0) + expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0) }, 60_000) it('stores the key under the derived reference and the route registers live', async () => { diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index fbfc85c7f1..0d50c03e63 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -47,7 +47,7 @@ export const en = { removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', - keyIllegalCharacters: 'This API key contains characters that cannot be sent. Paste the raw key only.', + keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', @@ -134,7 +134,7 @@ export const zh: typeof en = { removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', - keyIllegalCharacters: '该 API 密钥含有无法发送的字符。请只粘贴原始密钥。', + keyIllegalCharacters: '该 API 密钥格式错误,请检查。', keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', From a328fd34d58df0aa3c9ccf7036a849db05144a07 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:45:03 +0800 Subject: [PATCH 157/190] feat: subagent list use preparation + projection --- ...06-subagent-list-identity-projection.zh.md | 199 +++++++++ packages/host/apiproxy/src/api-proxy.ts | 41 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 6 +- packages/subagent/subagent/README.zh.md | 6 +- packages/subagent/subagent/package.json | 5 - packages/subagent/subagent/src/client.ts | 2 +- packages/subagent/subagent/src/index.ts | 43 +- .../subagent/subagent/src/list-children.ts | 352 ++++++++-------- .../subagent/subagent/src/projection-types.ts | 27 ++ packages/subagent/subagent/src/projection.ts | 69 +++- .../subagent/tests/list-children.spec.ts | 386 +++++++----------- .../tests/optional-session-query.spec.ts | 13 - packages/subagent/subagent/tsconfig.json | 3 - .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/README.zh.md | 2 +- .../tool-subagent-control/package.json | 7 - .../tool-subagent-control/src/list-agents.ts | 13 +- .../tests/list-agents.spec.ts | 7 +- .../tool-subagent-control/tsconfig.json | 3 - pnpm-lock.yaml | 6 - 22 files changed, 696 insertions(+), 504 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md delete mode 100644 packages/subagent/subagent/tests/optional-session-query.spec.ts diff --git a/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md new file mode 100644 index 0000000000..c4239ca903 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -0,0 +1,199 @@ +# Agent Note: subagent 列表经投影单元读取身份 + +Status: proposed + +[English](2026-08-06-subagent-list-identity-projection.md) | 中文 + +## 问题 + +`SubagentService.listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))对每个 `header.origin === 'subagent'` 的直接 child,每次列表都执行 `listEvents` 加 `readEvent` 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript 长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,`list_agents` 以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 整体拒绝,尽管枚举所需只是 header 事实。 + +同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()`([api-proxy.ts](../../../../packages/host/apiproxy/src/api-proxy.ts))在每次 Agent 绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 + +根因在于 [durable-subagent-catalog 决策](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为"无索引的正确性基线"。[web subagent conversations](../../implemented/feature/2026-07-27-web-subagent-conversations.md)(#1569)已把"是不是 subagent"放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 + +## 提案 + +mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 摘除 session-query 依赖——枚举由 subagent 自管的 live-preferred 合并完成,取值走 live/cold 两级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读),cold child 一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、无缓存、无回写。 + +消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。"值随查询索引落库"曾是本记录的定稿方向并一度施工,最终整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被"算完即止"显式接受。前两条与退役理由详见考虑过的替代方案一节。 + +方案要点: + +- **subagent 列表不再依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 +- **取值两级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——无缓存、无回写、无索引。 +- **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 +- **session-query 的净变化只剩读路径去 clone 加浅 readonly 借用视图**(附带工作项;DeepReadonly 被实证否决,见替代方案)。 +- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query-sqlite 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 + +与既有记录的关系: + +- 本记录取代 [durable-subagent-catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活契约不动。属部分取代,两记录保持交叉链接。 +- [session-projection RFC](2026-07-27-session-projection-and-command-log.md) 的 registry 契约(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。 + +### `subagent` projection unit + +挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`: + +```ts ignore-check +export type SubagentIdentityProjection = + | { mode: 'one-shot'; label?: string } + | { mode: 'continuable'; label: string } + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + subagent: SubagentIdentityProjection + } +} +``` + +- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果就是"无值",该 key 在这个 session 上缺席。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 +- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;该判别式与下文 child 行的 mode/label 强契约完全一致。 +- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。 + +### 枚举:subagent 自管 live-preferred 合并 + +`listChildren` 的枚举不再经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 优先、不做一致性校验。枚举所需全部是 header 事实: + +- 过滤:`header.origin === 'subagent' && header.parentSession === parentSessionId`。 +- `hasChildren`:同一份合并材料向下看一层——存在 `origin === 'subagent'` 且 `parentSession` 为该 child 的直接后代。 +- `activity`:live 记录为 `running`,仅存在于持久化的为 `inactive`。 +- 排序:`createdAt` 升序、再按 child id 升序(与旧契约一致)。 +- **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。) + +### 取值:两级"算完即止"阶梯 + +对每个枚举出的 child,mode/label 取值走两级阶梯,与 apiproxy `session.history` 的冷读同款——算完即止,无缓存、无回写: + +| 级 | 读法 | 成本 | +| --- | --- | --- | +| live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | +| cold child | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 | + +- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 随 session-query 依赖一并删除。 +- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,不影响 sibling(见四态映射)。 +- 冷读成本如实记录:cold child 每次列表一次整读,成本与其 transcript 大小成正比;定案"算完即止",不为它建缓存。整读经 `inspect()` 走 [Session 准备阶段](../../implemented/architecture/2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 + +### 权威模型 + +- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有 checkpoint、没有进程 memo,取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision。 +- Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。 +- 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。 + +### `listChildren` 行形状与消费面 + +`SubagentListEntry` **数据结构与今天完全一致**——child 与 diagnostic 两臂、`kind` 判别、reason 三值、child 臂的 mode/label 强契约全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身仍零事件读取。"没有就等待硬读取"继续保证阶梯对健康数据必然算得出 mode/label。 + +```ts ignore-check +export type SubagentListEntry = + | ({ + readonly kind: 'child' + readonly id: SessionId + readonly activity: 'running' | 'inactive' + readonly hasChildren: boolean + } & ( + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + )) + | { + readonly kind: 'diagnostic' + readonly id: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } +``` + +实现形态:`listChildren` = 自管枚举(id、activity、hasChildren、`origin` 过滤,全部来自 header 事实)+ 投影阶梯(mode/label)。逐 child 的 `listEvents`、精确 `readEvent`、描述符定位与就地分类机器整体删除。 + +对每个枚举出的 child,阶梯取值结果按四态映射成行: + +| 阶梯取值结果 | 行 | +| --- | --- | +| 快照含 `subagent` 值 | child 行 | +| 快照在、值缺席,且 child **inactive** | diagnostic 行,reason `corrupt`(定局残骸:无、损坏或版本不认识的描述符,不再细分) | +| 快照在、值缺席,且 child **running** | 行不出现(创建窗口:描述符尚未追加,与旧实现同窗口 omit) | +| cold 整读失败 | diagnostic 行,reason `unavailable` | + +- `unsupported` 不再被产出:类型与 wire 枚举按"数据结构保持现状"留存该成员,本记录留档其为不再产出。 +- descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。 + +已知边界偏差(有意接受,随本记录留档): + +- 死于发布窗口的 fork child,seed 里若有祖先描述符,last-wins 会给出祖先身份,误现为 child 行;恢复仍按 own-suffix 折叠权威失败(`NOT_RESUMABLE`)。旧实现靠 `seedLength` 过滤将其 omit;projection unit 看不到 header,接受此残骸级偏差(`subagentTiming` 有同类既有暴露)。 +- own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(provider 契约本就保证恰一)。 +- live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 +- 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 + +消费面:wire、tool、GUI 的 diagnostic 处理**全部保持现状零改动**(`list_agents` 的 description 与 output schema 亦不动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。唯一动行为的是 apiproxy 路由段:删 `hasSubagentDescriptor()` 扫描,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 + +### 附带工作项:session-query 读路去 clone 与浅 readonly + +- `SessionCorpus.load()`、`snapshotLive`、`listSessions` 等移除 structuredClone:live Session 的事件快照数组与事件载荷已深冻结(core/session 的 `deepFreeze` 加 `Object.freeze`),持久化读出的对象图为独占新建,克隆纯属浪费。 +- 公开查询输出标注**浅 readonly**(顶层属性与数组位);深只读化被实证否决(见替代方案),深层不可变由 core/session 的运行时深冻结事实保证,类型层面不再表达,`DeepReadonly` 不进任何公共包。 +- 契约措辞与 `projectMany` 的借用契约("borrowed only for that call")对齐:整个 corpus 面向消费方统一为"只读视图,不得留存可变引用"的不可变借用视图;需要留存的自行克隆。 + +### 改动面清单 + +| 区域 | 文件 | 改动 | +| --- | --- | --- | +| subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 | +| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` | +| session-query | index.ts、corpus.ts | 读路径去 clone,公开输出浅 readonly 借用视图(净变化仅此) | +| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin` | +| tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | +| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | **零改动**——行形状与 diagnostic 处理不变 | +| core/session、session-persistence、session-projection(-cache)、session-query-sqlite | — | **零改动** | +| 测试/快照 | 相关 spec 与 snapshot | 随行为更新,提 PR 前统一处理 | + +### 推进节奏 + +1. `subagent` projection unit 与注册(纯增量)。 +2. session-query:corpus 去 clone 与浅 readonly 借用视图。 +3. `listChildren` 重写(自管枚举 + 投影阶梯);tool 加载要求收窄;apiproxy 路由段 `hasSubagentDescriptor` 删除。 +4. 测试与快照统一更新,整体 diff 评审后再拆 commit。 + +配套文档随实现 PR 处理:[session-projection RFC](2026-07-27-session-projection-and-command-log.md) 增补一节,记录 `subagent` 身份 unit 与 snapshot/restore 两处既有读法的消费实例(registry 契约零改动);[durable-subagent-catalog 记录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的列表读路径段落随实现更新并与本记录交叉链接。 + +## 考虑过的替代方案 + +**mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是"第一次列表一次 `inspect` 现算",不碰持久格式。 + +**projection-cache 阶梯(v3 稿:`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但它给 subagent 域在 `sessionProjections` 之外再引入 `sessionProjectionCache` 依赖,且 checkpoint 是一套新增的派生数据持久化与失效编排(floor/identity/putSoft);读时现算不需要任何持久派生。 + +**给 persistence 加有界读原语抢救存量。** 为一次性问题新开 seam 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。 + +**list 行 mode/label 可选化(v4 一稿)。** 健康数据必然可算;可选化只是把垃圾数据的处理复杂度外溢给全部消费方——每个消费面都要长出过滤分支和 unknown 展示态。强契约加算不出即 omit 更干净。 + +**彻底删除 diagnostic 行(v5 一稿)。** 删除把库损坏的可见性外溢为行静默消失,wire/tool/GUI 反要各自承担契约与快照变更;而保留只需列表侧按投影值缺席与 activity 派生分类,零成本。库里的坏、死子会话必须可见是 diagnostic 存在的原始动机,保留后消费面整体零改动。 + +**registry 计算失败通道(per-unit 容错加 `failures` 附加字段)。** 为把损坏、版本不认识报告给消费方,曾考虑让 registry 捕获 unit 异常并在 snapshot 旁附 per-key 失败态。被否:failure 不是值,也不必是通道——unit 永不抛错,缺席本身就是信号,"大不了算出来没有",如何呈现是消费方要考虑的事。该路线讨论顺带留下一个独立观察:vendor cordis 的 `emit`([vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts))对 listener 抛错零捕获,投影驱动挂在 `session/event` 上时 unit 异常会沿 emit 逃逸——这加重了"unit 永不抛错"纪律的分量,但 emit 容错的修复不属于本记录范围。 + +**值随 query 索引 preparation 落库(v4/v5 定稿,一度施工)。** 投影值在 sqlite backend 的对账重建里折叠落进 session 索引行,读稳态零日志;`projectionsFor` 批量读面、行值随 `(key → stateVersion)` 注册集存储的失效对账与 SCHEMA bump 均已施工过。整体退役:方向反了——查询基础设施被迫认识领域词汇(投影列、注册集对账),而唯一消费方 subagent 列表读时现算即可满足;消费方归零后,这套派生持久化没有存在理由。`SESSION_QUERY_PROJECTIONS_UNAVAILABLE` 随读面一并删除。 + +**subagent 手工 parse 加进程 memo 加创建播种(v6 稿)。** 为摘除 session-query 依赖,曾考虑 subagent 自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被 v7 阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走 `registry.restore`,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。 + +**session-query 输出面 DeepReadonly(去 clone 一稿)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);退回浅 readonly,深层不可变由 core/session 的运行时深冻结保证。 + +## 验收标准 + +- 稳态列表读代价:live child 全程零 events 读取(仅注册表水位缓存);cold child 每次 `listChildren` 恰一次 `persistence.inspect` 整读;由 subagent 测试断言。 +- 行为等价:同一语料下,新实现产出与旧实现相同的行集合(child 行的 id、mode、label、activity、hasChildren 与 diagnostic 行的 id、reason),例外仅限本记录留档的语义变化——descriptor-less 定局残骸由 omit 改为 `corrupt` 行、`unsupported` 归并入 `corrupt`、四条边界偏差(stillborn fork 祖先身份、多描述符 last-wins、header 冲突不再察觉、损坏源读失败由 `corrupt` 转 `unavailable`)——且每处变化有测试钉住新行为。 +- 四态映射成立:快照有值成 child 行;inactive 缺值产生 `corrupt` 行(含 descriptor-less 定局残骸);running 缺值缺席(创建窗口);cold 整读失败映射 `unavailable`;`unsupported` 不再产出。 +- 错误契约:`ctx.sessionProjections` 未挂载时 `listChildren` 于枚举前以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 失败(零 children 部署同样确定失败);`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 从代码与文档中消失。 +- persistence 缺席退为 live-only 枚举,不报错,live child 照常成行。 +- per-child 隔离:单 child 整读失败只产生该行 `unavailable`,sibling 不受影响。 +- `hasSubagentDescriptor` 删除后属主判定只认 `header.origin`;`list_agents` 的 description、output schema 与既有无密钥快照零变化,钉住 wire/tool/GUI 零改动。 +- corpus 去 clone 后公开输出为浅 readonly 借用视图,既有 session-query 行为测试全数通过。 + +## 风险 + +- **折叠规则分叉。** "折叠只在 registry 一份"是本设计的承诺;若未来某消费面绕开 registry 手写折叠,各读面的值可能漂移。缓解:列表两级阶梯与 GUI history 冷读走的都是 registry 的同两处读法(snapshot/restore),不存在旁路折叠。 +- **cold child 的每次列表整读成本。** cold child 每次 `listChildren` 都做一次 `inspect` 整读现算,成本与其 transcript 大小成正比、随列表频率重复;定案"算完即止",不建缓存、不回写。同 id 短期重复整读可命中持久化协调器准备阶段的 LRU 复用,但列表不依赖它;live child 全程零读。显式接受。 +- **诊断语义的四处边界偏差。** stillborn fork 的祖先身份误现为 child 行、多描述符改取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`——完整语义与接受理由见提案的已知边界偏差清单。均为残骸级数据的展示或分类偏差,恢复鉴权不受影响。 +- **pre-#1569 存量属主判定收窄。** 无 `origin` 的旧 child 不再被认作 subagent 属主。其本就不进目录,pre-release 无兼容承诺,接受。 + +## 相关 + +- [durable-subagent-catalog 与 list_agents](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 +- [session projections 与命令生命周期日志](2026-07-27-session-projection-and-command-log.md)——registry 契约的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。 +- [web subagent conversations](../../implemented/feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 +- [发布前可复用的 Session 准备阶段](../../implemented/architecture/2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f528b2297e..9a93b89d99 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -567,6 +567,15 @@ function subagentPromptError( return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} }) } +/** Stable RPC face of the missing projections capability, shared by every catalog read path. */ +function projectionsUnavailableError(): RpcError { + return { + code: 'internal', + message: 'subagent listing is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + details: {}, + } +} + /** Verify one address and mode against the complete direct-child catalog. */ async function catalogChild( ctx: Context, @@ -605,6 +614,9 @@ async function catalogChild( || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } } } + if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { + return { error: projectionsUnavailableError() } + } if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { return { error: { @@ -925,28 +937,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } - /** Whether the session's own suffix carries the durable subagent discriminator. */ - function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean { - const events = session.events - // Indexed scan from the own-suffix start: slicing copies the whole suffix - // on every Agent-bound RPC, including each `session.prompt` on long - // transcripts. - for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) { - if (events[index]?.type === 'subagent/descriptor') return true - } - return false - } - /** - * Generic Host interaction cannot claim a durably classified subagent or an - * Agent created through its live parent. The runtime-owner arm also covers - * descriptor-less child publication windows and older stored headers. + * Generic Host interaction cannot claim a durably classified subagent + * (`origin: 'subagent'` in the header) or an Agent runtime-owned by its + * live parent. */ function hasSubagentOwner( - session: Pick<Session, 'events' | 'header'>, + session: Pick<Session, 'header'>, agent: Agent | undefined, ): boolean { - if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true + if (session.header.origin === 'subagent') return true const parentId = session.header.parentSession if (parentId === undefined || agent === undefined) return false const parent = ctx.agents.get(parentId) @@ -1002,7 +1002,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro resume = (async () => { try { const inspected = await inspectServable(sessionId) - if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { + if (hasSubagentOwner({ header: inspected.meta }, undefined)) { throw new SubagentSessionOwnership(sessionId) } const publishedSession = ctx.sessions.get(sessionId) @@ -1121,7 +1121,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Ownership first: explicit-id adoption of a session-backed // subagent must answer `agent-busy` regardless of the requested // cwd (the api/commands.ts contract), not a cwd conflict. - if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { + if (hasSubagentOwner({ header: inspected.meta }, undefined)) { throw new SubagentSessionOwnership(sessionId) } if (inspected.meta.cwd !== cwd) { @@ -1912,6 +1912,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } + if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { + return err(request, projectionsUnavailableError()) + } return err(request, { code: 'internal', message: 'subagent catalog read failed', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index bc73d345e5..72833b7ddb 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: 08b6175e018db072b99490a25b8df887bb89eb47 -README.zh.md: 435be7660b3f004a1f0bcb59a8d9a74ac8e8aae3 +README.md: bfed362d5a70bf946295c04d02ed1c6d031041e3 +README.zh.md: 11121735bd4acdfccf2ef950d30e5913646430a4 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 08b6175e01..bfed362d5a 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -21,7 +21,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. | | `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | -| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. | +| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. | `SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. @@ -78,13 +78,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. -When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. +When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to no value, indistinguishable from a log with no descriptor, and never throws. `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Each healthy row derives its read-time `hasChildren` hint from traced direct-descendant headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child, one bounded-concurrency persistence inspection folded through the registry for a cold one. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 435be7660b..11121735bd 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -21,7 +21,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 | | `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 | | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | -| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 | +| `listChildren(parentSessionId, signal?)` | 按 `createdAt` 再按 id 的顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。直接读取在线会话存储与可选的会话持久化(持久化缺席时仅枚举在线 child),并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 | `SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。 @@ -78,13 +78,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 -当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。 +当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为无值,与没有描述符的日志不可区分,且绝不抛错。 `registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个健康条目都会根据追踪结果中携带持久化 `origin: 'subagent'` 的直接后代 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照,冷 child 经一次有界并发的持久化 inspect 再经注册表折叠。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index c00caf7fb9..04ae941633 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -40,7 +40,6 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -50,9 +49,6 @@ "@deepseek-ai/dsh-session-persistence": { "optional": true }, - "@deepseek-ai/dsh-session-query": { - "optional": true - }, "@deepseek-ai/dsh-session-projection": { "optional": true }, @@ -68,7 +64,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/subagent/src/client.ts b/packages/subagent/subagent/src/client.ts index 928637dc7a..602dcd8793 100644 --- a/packages/subagent/subagent/src/client.ts +++ b/packages/subagent/subagent/src/client.ts @@ -4,4 +4,4 @@ * @module @deepseek-ai/dsh-subagent/client */ -export type { SubagentTimingProjection } from './projection-types.ts' +export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 975d07ce4c..57634ecfac 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -20,8 +20,8 @@ * continuation manager holds their `AgentHandle` directly and orders every turn * through the child's own inbox, so providers contribute only the detached * creation spec and see no handle, turn, or teardown. Direct-child discovery - * independently interprets the optional session-query corpus and does not - * require that continuation runtime. + * reads the live session store and optional session persistence directly and + * does not require that continuation runtime. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -65,7 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t import { listChildren as listSubagentChildren } from './list-children.ts' import type { SubagentListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' -import { subagentTimingProjectionDefinition } from './projection.ts' +import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' @@ -118,7 +118,7 @@ export type { export type { ContinuableSetupContribution } from './activation-setup-registry.ts' export type { SubagentListEntry } from './list-children.ts' export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' -export type { SubagentTimingProjection } from './projection-types.ts' +export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' declare module 'cordis' { interface Context { @@ -190,6 +190,7 @@ export class SubagentService extends Service { }) ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) + projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) }) } @@ -283,22 +284,28 @@ export class SubagentService extends Service { } /** - * Enumerate the parent's direct session-backed subagents from the - * live-preferred session corpus without loading or resuming an Agent. Session - * query supplies lineage, candidate order, event reads, and live state; this - * service interprets descriptor mode, activity, and per-child diagnostics - * without consulting Agent registrations, Activations, or providers. + * Enumerate the parent's direct session-backed subagents without loading or + * resuming an Agent and without any query seam: the listing merges the live + * session store with optional session persistence (live-preferred) and + * serves each child's durable mode/label from the registered `subagent` + * projection unit — the registry's watermark snapshot for a live child, one + * persistence inspection folded through the registry for a cold one. The + * projection fold is the single classification authority; per-child + * diagnostics relay a fold that served no identity or a failed inspection, + * never a list-time descriptor parse. Absent persistence, enumeration is + * live-only (a cold child cannot be resumed then either, so its absence is + * capability absence, not an error). This service consults no Agent + * registrations, Activations, or providers. * - * The trace and exact descriptor read receive `signal`; the full event-list - * read has no signal parameter, so the scan rechecks cancellation around - * every await and between candidates. Query rejections that settle after an - * abort become a stable `SubagentError` with code `CANCELLED`. + * Every persistence read receives `signal`, and the listing rechecks + * cancellation around each of those awaits. Read rejections that settle + * after an abort become a stable `SubagentError` with code `CANCELLED`. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation forwarded where supported and - * observed around every query await. - * @returns children and per-child diagnostics in stable trace order. - * @throws {@link SubagentError} when session query is unavailable or the - * caller cancels the scan. + * @param signal - caller-owned cancellation forwarded to persistence reads + * and observed around every read await. + * @returns children and per-child diagnostics ordered by `createdAt`, then id. + * @throws {@link SubagentError} when the projection registry is not mounted + * or the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> { return listSubagentChildren(this.ctx, parentSessionId, signal) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index cabbec121d..6b055be4a6 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -1,33 +1,39 @@ /** - * Read-only interpretation of session-query lineage as durable subagent - * children. Only descendants with durable `origin: 'subagent'` enter per-child - * inspection. The module owns no catalog state and does not consult Activation, - * Agent-registry, continuation-manager, or provider state. A child's descriptor - * distinguishes one-shot work from a continuable conversation. + * Read-only enumeration of one parent's durable subagent children straight + * from the live session store and optional session persistence — no query + * seam. Candidates are the live-preferred merge of both listings filtered to + * durable `origin: 'subagent'` under the parent; each child's mode/label is + * the registered `subagent` projection unit's value, served from the + * registry's watermark cache for a live child and folded once over one + * persistence inspection for a cold one. The projection fold is the single + * classification authority — this module parses no descriptor itself. Absent + * persistence, enumeration is live-only: a cold child is unreachable for + * resume anyway, so its absence is capability absence, not an error. The + * module owns no catalog state and does not consult Activation, + * Agent-registry, continuation-manager, or provider state. * * @module @deepseek-ai/dsh-subagent */ import type { Context } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query' -import type SubagentService from './index.ts' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import { SubagentError } from './error.ts' -import { foldSubagentDescriptor } from './descriptor.ts' +import type { SubagentIdentityProjection } from './projection-types.ts' -type SessionQueryRuntime = Pick< - typeof import('@deepseek-ai/dsh-session-query'), - 'assertSessionHeadersCompatible' | 'SessionQueryError' -> +/** Concurrent cold inspections per listing; a constant because it bounds one read-only scan, not deployment behavior. */ +const COLD_READ_CONCURRENCY = 4 /** - * One entry of a {@link listChildren} result in trace candidate order. Only a - * candidate whose durable header has `origin: 'subagent'` is inspected. A - * valid descriptor produces a `child`, a per-child inspection failure produces - * a `diagnostic`, and a candidate without its own descriptor is omitted. - * Healthy rows include a one-level, origin-classified descendant hint. - * Diagnostics are transient query results, never session events or catalog - * state, and never expose model-hidden descriptor content. + * One entry of a {@link listChildren} result, ordered by header `createdAt` + * with ties broken on id. Only a candidate whose durable header has + * `origin: 'subagent'` is interpreted. A served `subagent` projection value + * produces a `child`; a settled candidate whose fold served no identity + * produces a `diagnostic`; a running candidate without one is omitted — its + * descriptor may not be appended yet (the creation window). Diagnostics + * relay the projection fold's outcome or a failed read, never a per-child + * event scan, and never expose model-hidden descriptor content. */ export type SubagentListEntry = | { @@ -35,7 +41,7 @@ export type SubagentListEntry = /** The durable child session id, stable across Activations. */ readonly id: SessionId /** - * Corpus snapshot activity: `running` means the logical record is live in + * Store snapshot activity: `running` means the logical record is live in * `ctx.sessions`; `inactive` means it exists only in persistence. Neither * encodes a durable outcome, and a continuable child may still reject * delivery as an ownership conflict. @@ -59,179 +65,185 @@ export type SubagentListEntry = ) | { readonly kind: 'diagnostic' - /** The traced candidate's session id. */ + /** The candidate's session id. */ readonly id: SessionId /** - * Why the candidate was omitted: `corrupt` for invalid surfaces, header - * conflicts, or malformed/duplicated descriptors; `unsupported` for an - * unknown descriptor version; `unavailable` when the child disappeared or - * its per-child read hit a persistence failure. + * Why the candidate has no `child` row: `corrupt` for a settled candidate + * whose projection fold served no identity (a missing, malformed, or + * unrecognized-version descriptor — deliberately undistinguished); + * `unavailable` when the candidate's persistence inspection failed + * (retried on the next listing). `unsupported` is kept for consumers + * already routing on it but is no longer produced. */ readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } /** - * Interpret one parent's origin-classified direct descendants as session-backed - * subagents without loading or resuming an Agent. Ordinary forks are skipped - * before per-child event inspection. - * @see {@link SubagentService.listChildren} for the public cancellation and - * failure contract. - * @param ctx - context carrying the optional session-query service. + * Enumerate one parent's origin-classified direct children from the + * live-preferred merge of `ctx.sessions` and optional session persistence, + * serving each identity from the `subagent` projection unit: the registry's + * watermark snapshot for a live child, one bounded-concurrency persistence + * inspection folded through the registry for a cold one. + * @see SubagentService.listChildren for the public cancellation and failure contract. + * @param ctx - context carrying the session store, the projection registry, + * and optional persistence. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation. - * @returns children and per-child diagnostics in stable trace order. - * @throws {@link SubagentError} when session query is unavailable or - * the caller cancels the scan. + * @param signal - caller-owned cancellation observed around every persistence read. + * @returns children and per-child diagnostics ordered by `createdAt`, then id. + * @throws {@link SubagentError} when the projection registry is not mounted + * or the caller cancels the listing. */ export async function listChildren( ctx: Context, parentSessionId: SessionId, signal?: AbortSignal, -): ReturnType<SubagentService['listChildren']> { - const query = ctx.get('sessionQuery') - if (query === undefined) { +): Promise<SubagentListEntry[]> { + const projections = ctx.get('sessionProjections') + const sessions = ctx.get('sessions') + // Checked before any read, even with zero candidates: mode/label are the + // row's strong contract, so a missing fold capability is a deterministic + // deployment configuration error, never an empty success. + if (projections === undefined) { throw new SubagentError( - 'listing subagents requires session query (load a dsh-session-query backend)', - 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE', + 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', + ) + } + if (sessions === undefined) { + throw new SubagentError( + 'listing subagents requires the sessions registry (load @deepseek-ai/dsh-session)', + 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', ) } assertListingNotCancelled(signal) - // Keep runtime values behind the listing-only boundary so ordinary - // subagent imports and control operations do not evaluate the optional peer. - const queryRuntime: SessionQueryRuntime = await import('@deepseek-ai/dsh-session-query') - assertListingNotCancelled(signal) - const trace = await runListingQuery( - () => query.traceSession(parentSessionId, signal), - signal, - ) - const entries: SubagentListEntry[] = [] - for (const node of trace.descendants) { - if (node.session.header.origin !== 'subagent') continue - const hasChildren = node.descendants.some( - descendant => descendant.session.header.origin === 'subagent', - ) - const entry = await inspectChild( - query, queryRuntime, parentSessionId, node.session, hasChildren, signal, - ) - // Cancellation can race the inspection's last checkpoint or diagnostic - // mapping; do not return success or begin another candidate afterward. - assertListingNotCancelled(signal) - if (entry !== undefined) entries.push(entry) - } - return entries -} - -/** Interpret one traced direct-child record as a child, diagnostic, or exclusion. */ -async function inspectChild( - query: SessionQueryService, - queryRuntime: SessionQueryRuntime, - parentSessionId: SessionId, - candidate: SessionRecord, - hasChildren: boolean, - signal?: AbortSignal, -): Promise<SubagentListEntry | undefined> { - const childId = candidate.header.id - try { - const records = await runListingQuery(() => query.listEvents(childId), signal) - // Only the child's own suffix: a fork seed may replay an ancestor's - // descriptor without making the fork itself a subagent. - const seedLength = candidate.header.seedLength ?? 0 - const descriptorSeqs = records - .filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor') - .map(record => record.seq) - if (descriptorSeqs.length === 0) return undefined - if (descriptorSeqs.length > 1) { - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } - } - // The length-one branch proves this exact-read sequence exists. - // oxlint-disable-next-line typescript/no-non-null-assertion - const seq = descriptorSeqs[0]! - const window = await runListingQuery( - () => query.readEvent({ sessionId: childId, seq }, signal), - signal, - ) - queryRuntime.assertSessionHeadersCompatible(window.session, candidate.header) - if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') { - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } - } - let descriptor: ReturnType<typeof foldSubagentDescriptor> + const persistence = ctx.get('sessionPersistence') + let persistedHeaders: readonly SessionHeader[] = [] + if (persistence !== undefined) { try { - descriptor = foldSubagentDescriptor([window.target]) - } catch { - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + persistedHeaders = await persistence.list(signal) + } catch (error: unknown) { + // The backend may reject with its own abort failure after observing the + // forwarded signal; cancellation stays a stable subagent failure. + assertListingNotCancelled(signal) + throw error } - if (descriptor === undefined) { - return { kind: 'diagnostic', id: childId, reason: 'unsupported' } - } - const activity = candidate.live ? 'running' : 'inactive' - if (descriptor.mode === 'one-shot') { - return { - kind: 'child', - id: childId, - mode: descriptor.mode, - ...descriptor.label !== undefined ? { label: descriptor.label } : {}, - activity, - hasChildren, - } - } - return { - kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label, - activity, hasChildren, - } - } catch (error: unknown) { - const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError) - if (reason === undefined) throw error - return { kind: 'diagnostic', id: childId, reason } + assertListingNotCancelled(signal) } + // Live-preferred merge without header reconciliation: a live record wins + // its id wholesale, exactly as a live-preferred corpus would serve it. + const corpus = new Map<SessionId, { header: SessionHeader; live: Session | undefined }>() + for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined }) + for (const session of sessions.list()) { + corpus.set(session.header.id, { header: session.header, live: session }) + } + const subagentParents = new Set<SessionId>() + for (const record of corpus.values()) { + if (record.header.origin === 'subagent' && record.header.parentSession !== undefined) { + subagentParents.add(record.header.parentSession) + } + } + const candidates = [...corpus.values()] + .filter(record => record.header.parentSession === parentSessionId + && record.header.origin === 'subagent') + .sort((a, b) => a.header.createdAt - b.header.createdAt + || (a.header.id < b.header.id ? -1 : a.header.id > b.header.id ? 1 : 0)) + + const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length }) + const coldReads: { index: number; id: SessionId }[] = [] + candidates.forEach((candidate, index) => { + const childId = candidate.header.id + if (candidate.live === undefined) { + coldReads.push({ index, id: childId }) + return + } + // The registry's watermark cache serves the live value with zero log + // reads; a live child without an identity yet is the creation window + // before the establishing provider appends its descriptor. + const identity = projections.snapshot(candidate.live).values.subagent + if (identity === undefined) return + rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId)) + }) + + // Cold candidates exist only when persistence listed them, so the narrow + // re-check is about types, not reachability. + if (persistence !== undefined && coldReads.length > 0) { + const queue = [...coldReads] + await Promise.all(Array.from( + { length: Math.min(COLD_READ_CONCURRENCY, queue.length) }, + async () => { + for (let job = queue.shift(); job !== undefined; job = queue.shift()) { + rows[job.index] = await inspectColdIdentity( + persistence, projections, job.id, subagentParents.has(job.id), signal, + ) + } + }, + )) + } + assertListingNotCancelled(signal) + return rows.filter((row): row is SubagentListEntry => row !== undefined) } -/** Stop a listing scan at its next cancellation checkpoint. */ +/** + * Resolve one cold candidate: one persistence inspection folded through the + * projection registry (the same detached recipe the API proxy uses for + * detached session projections). A failed inspection is one transient + * `unavailable` row retried on the next listing; a settled log the fold + * cannot identify is final, so it reports `corrupt`. + */ +async function inspectColdIdentity( + persistence: SessionPersistence, + projections: SessionProjectionRegistry, + childId: SessionId, + hasChildren: boolean, + signal: AbortSignal | undefined, +): Promise<SubagentListEntry | undefined> { + assertListingNotCancelled(signal) + let events: readonly SessionEvent[] + try { + events = (await persistence.inspect(childId, signal)).events + } catch { + // Per-child isolation: the child vanished or its backend read failed — + // one diagnostic row, and the listing itself still succeeds. + assertListingNotCancelled(signal) + return { kind: 'diagnostic', id: childId, reason: 'unavailable' } + } + assertListingNotCancelled(signal) + const identity = projections.restore({}, events, 0).snapshot.values.subagent + if (identity === undefined) { + return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + } + return childRow(childId, identity, 'inactive', hasChildren) +} + +/** Materialize one served identity as its child row. */ +function childRow( + id: SessionId, + identity: SubagentIdentityProjection, + activity: 'running' | 'inactive', + hasChildren: boolean, +): SubagentListEntry { + return identity.mode === 'one-shot' + ? { + kind: 'child', + id, + mode: 'one-shot', + ...identity.label !== undefined ? { label: identity.label } : {}, + activity, + hasChildren, + } + : { + kind: 'child', + id, + mode: 'continuable', + label: identity.label, + activity, + hasChildren, + } +} + +/** Stop a listing at its next cancellation checkpoint. */ function assertListingNotCancelled(signal: AbortSignal | undefined): void { if (signal?.aborted) { throw new SubagentError('subagent listing was cancelled', 'CANCELLED') } } - -/** - * Run one session-query operation between cancellation checkpoints. Query - * implementations may reject with their own abort error after observing the - * forwarded signal; cancellation remains a stable subagent failure. - */ -async function runListingQuery<T>( - operation: () => Promise<T>, - signal: AbortSignal | undefined, -): Promise<T> { - assertListingNotCancelled(signal) - try { - const result = await operation() - assertListingNotCancelled(signal) - return result - } catch (error: unknown) { - assertListingNotCancelled(signal) - throw error - } -} - -/** - * Map a per-child query failure to a fixed diagnostic. Configuration errors - * and unrecognized failures remain operation failures. - */ -function perChildDiagnosticReason( - error: unknown, - SessionQueryError: SessionQueryRuntime['SessionQueryError'], -): 'corrupt' | 'unavailable' | undefined { - if (!(error instanceof SessionQueryError)) return undefined - switch (error.code) { - case 'SESSION_QUERY_CORRUPT_SESSION': - return 'corrupt' - case 'SESSION_QUERY_SESSION_NOT_FOUND': - case 'SESSION_QUERY_EVENT_NOT_FOUND': - case 'SESSION_QUERY_PERSISTENCE_FAILED': - return 'unavailable' - case 'SESSION_QUERY_INVALID_SURFACE': - case 'SESSION_QUERY_SOURCE_CONFLICT': - return 'corrupt' - default: - return undefined - } -} diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index c5a23b03b8..a92ed3a882 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -17,9 +17,36 @@ export interface SubagentTimingProjection { } } +/** + * Durable identity of one descriptor-backed subagent session: lifecycle mode + * plus creation label, folded last-wins from `subagent/descriptor` events. + * Label strength follows the descriptor schema: a continuable child always + * carries one, a one-shot child may omit it. + */ +export type SubagentIdentityProjection = + | { + /** A terminal one-shot child. */ + mode: 'one-shot' + /** Optional durable creation label from the child's descriptor. */ + label?: string + } + | { + /** A resumable conversation. */ + mode: 'continuable' + /** Durable creation label from the child's descriptor. */ + label: string + } + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { /** Active-turn duration for a descriptor-backed subagent session. */ subagentTiming: SubagentTimingProjection + /** + * Identity of a descriptor-backed subagent session. No value ⟺ no valid + * descriptor: a missing, malformed, or unrecognized-version descriptor is + * served identically as `undefined` in a live snapshot, and as an absent + * key after any JSON boundary (query-index rows, wire frames) drops it. + */ + subagent: SubagentIdentityProjection } } diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index ffdcb4fd09..41b0d093ad 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -1,12 +1,16 @@ /** - * Pure session projection for subagent active-turn duration. + * Pure session projections for subagent identity (mode/label) and active-turn + * duration. * * @module @deepseek-ai/dsh-subagent/projection */ import { z } from 'zod' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type { SubagentTimingProjection } from './projection-types.ts' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { foldSubagentDescriptor } from './descriptor.ts' +import type { SubagentDescriptorData } from './descriptor.ts' +import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' interface TimingState { /** Milliseconds accumulated across completed post-descriptor turns. */ @@ -80,3 +84,64 @@ ProjectionDefinition<'subagentTiming', TimingState> = { }), stateVersion: 2, } + +interface IdentityState { + /** Identity from the last valid descriptor; absent before one, and after an invalid one. */ + identity?: SubagentIdentityProjection +} + +// Zod's optional output includes explicit `undefined`; with +// exactOptionalPropertyTypes the public map entry permits omission only, and +// JSON boundaries drop the undefined-valued key entirely. +const identitySchema = z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal('one-shot'), + label: z.string().optional(), + }).strict(), + z.object({ + mode: z.literal('continuable'), + label: z.string(), + }).strict(), +]).optional() as unknown as z.ZodType<SubagentIdentityProjection> + +/** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */ +function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined { + let descriptor: SubagentDescriptorData | undefined + try { + descriptor = foldSubagentDescriptor([event]) + } catch { + // Only a malformed current-version payload throws in descriptor parsing; + // a projection fold must never throw, so damage folds to no value. + descriptor = undefined + } + if (descriptor === undefined) return undefined + return descriptor.mode === 'one-shot' + ? { mode: 'one-shot', ...descriptor.label !== undefined ? { label: descriptor.label } : {} } + : { mode: 'continuable', label: descriptor.label } +} + +/** + * Fold the durable mode/label identity from `subagent/descriptor` events, + * last-wins: a fork seed may replay an ancestor's descriptor, and the child's + * own descriptor must override it — the same reset discipline as + * {@link subagentTimingProjectionDefinition}. A malformed or unknown-version + * payload resets to no value instead of throwing, so a fork of a healthy + * ancestor never inherits an identity its own descriptor failed to establish; + * no value ⟺ no valid descriptor, with the causes deliberately undistinguished. + */ +export const subagentIdentityProjectionDefinition: +ProjectionDefinition<'subagent', IdentityState> = { + key: 'subagent', + schema: identitySchema, + init: () => ({}), + apply: (state, event) => { + if (event.type !== 'subagent/descriptor') return state + const identity = descriptorIdentity(event) + return identity === undefined ? {} : { identity } + }, + // A no-value log serves `undefined` (the schema's optional side); the map + // entry stays non-optional because every consumer reads through `Partial` + // snapshot values, where absence is already the type. + view: state => state.identity as SubagentIdentityProjection, + stateVersion: 1, +} diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index f8ceab50a8..18aab0a2ca 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError, @@ -17,7 +17,6 @@ import SubagentService, { import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts' type Script = ConstructorParameters<typeof MockAdapter>[0] @@ -26,18 +25,18 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -/** Boot the continuable stack plus a concrete session-query service. */ -async function setup(script: Script, options: { sessionQuery?: boolean } = {}) { +/** Boot the continuable stack with real JSONL session persistence. */ +async function setup(script: Script, options: { sessionProjections?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) - if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } @@ -102,37 +101,38 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) } describe('SubagentService.listChildren', () => { - it('lists through session query without the Activation continuation runtime', async () => { + it('lists live children without persistence, query services, or the continuation runtime', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(TestSessionQueryService) expect(ctx.get('tasks')).toBeUndefined() expect(ctx.get('agents')).toBeUndefined() + expect(ctx.get('sessionPersistence')).toBeUndefined() - const parentId = SessionId('query-only-parent') + const parentId = SessionId('live-only-parent') ctx.sessions.create(parentId) - const childId = SessionId('query-only-child') + const childId = SessionId('live-only-child') const child = ctx.sessions.create(childId, { meta: { parentSession: parentId, origin: 'subagent' }, }) child.append('turn/start', { turn: 1, }) - child.append('subagent/descriptor', descriptorPayload('query-only child')) + child.append('subagent/descriptor', descriptorPayload('live-only child')) await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([ { - kind: 'child', id: childId, label: 'query-only child', mode: 'continuable', + kind: 'child', id: childId, label: 'live-only child', mode: 'continuable', activity: 'running', hasChildren: false, }, ]) }) - it('fails loud before any work when session query is not loaded', async () => { - const { ctx, parent } = await setup([], { sessionQuery: false }) + it('fails loud when the projection registry is not mounted, even with no children', async () => { + const { ctx, parent } = await setup([], { sessionProjections: false }) await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error, + expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, ) }) @@ -148,7 +148,7 @@ describe('SubagentService.listChildren', () => { ]) }) - it('lists one-shot and continuable children from the same trace', async () => { + it('lists one-shot and continuable children under the same parent', async () => { const { ctx, parent } = await setup([textResponse('once'), textResponse('again')]) const oneShot = await ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'finish once' }], @@ -205,7 +205,7 @@ describe('SubagentService.listChildren', () => { ]) }) - it('orders children by createdAt then id without inspecting ordinary forks', async () => { + it('orders children by createdAt then id without listing ordinary forks', async () => { const { ctx, parent } = await setup([]) // Authored headers pin the ordering key deterministically: same createdAt // ties break on id, different createdAt orders ascending. @@ -227,11 +227,11 @@ describe('SubagentService.listChildren', () => { // An ordinary session fork shares parentSession but has no subagent origin. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) await ctx.sessions.flush(fork) - const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents') + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') const entries = await ctx.subagents.listChildren(parent.id) expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late]) expect(entries.every(entry => entry.kind === 'child')).toBe(true) - expect(listEvents).not.toHaveBeenCalledWith(fork.id) + expect(inspect).not.toHaveBeenCalledWith(fork.id, expect.anything()) }) it('reports a live child as running while keeping settled siblings complete', async () => { @@ -256,7 +256,7 @@ describe('SubagentService.listChildren', () => { }) }) - it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => { + it('lists the last descriptor when a log carries more than one', async () => { const { ctx, parent } = await setup([textResponse('done')]) const healthy = await startChild(ctx, parent, 'healthy sibling') const events = childEvents(descriptorPayload('twice')) @@ -267,22 +267,27 @@ describe('SubagentService.listChildren', () => { data: descriptorPayload('twice again'), } as SessionEvent) events[4] = { ...events[4]!, seq: 4 } - const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', { + const doubled = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', { parentSession: parent.id, origin: 'subagent', }, events) + // The last-wins projection fold serves the final descriptor's identity; a + // repeated descriptor is not a per-child corruption diagnostic. const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' }) + expect(entries).toContainEqual({ + kind: 'child', id: doubled, label: 'twice again', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) expect(entries).toContainEqual({ kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', activity: 'inactive', hasChildren: false, }) }) - it('diagnoses a child rejected by persisted Session preparation as corrupt', async () => { + it('maps a child rejected by persistence inspection to unavailable', async () => { const { ctx, parent } = await setup([]) - // The surface-eligible user/message lacks its required surfaceOp. The - // first-party persistence inspection rejects before session-query can fold it. + // The surface-eligible user/message lacks its required surfaceOp, so the + // first-party inspection rejects before any projection fold can run. const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', { parentSession: parent.id, origin: 'subagent', @@ -297,7 +302,7 @@ describe('SubagentService.listChildren', () => { { type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') }, ] as SessionEvent[]) const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }]) + expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'unavailable' }]) }) it('diagnoses a malformed descriptor payload as corrupt', async () => { @@ -310,28 +315,36 @@ describe('SubagentService.listChildren', () => { expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }]) }) - it('diagnoses an unknown descriptor version as unsupported', async () => { + it('diagnoses an unknown descriptor version as corrupt', async () => { const { ctx, parent } = await setup([]) const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', { parentSession: parent.id, origin: 'subagent', }, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1))) + // The projection fold does not distinguish an unrecognized version from + // other invalid descriptors: both serve no identity, and a settled + // no-value candidate is corrupt. const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }]) + expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'corrupt' }]) }) - it('ignores an ancestor descriptor replayed inside a fork seed', async () => { + it('lists a fork whose seed replays an ancestor descriptor under that identity', async () => { const { ctx, parent } = await setup([]) - // A fork child whose seed replays a parent log containing a descriptor: - // the seed's descriptor is the ANCESTOR's, not this child's. + // The last-wins fold serves a seed-replayed ancestor descriptor until the + // child's own descriptor overrides it (known deviation #1 in the design). const seed = childEvents(descriptorPayload('ancestor label')) - await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { + const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', { parentSession: parent.id, seedLength: seed.length, origin: 'subagent', }, seed) const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([]) + expect(entries).toEqual([ + { + kind: 'child', id: forkChild, label: 'ancestor label', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }, + ]) }) it('does not filter by provider availability: children of unmounted providers stay listed', async () => { @@ -354,103 +367,35 @@ describe('SubagentService.listChildren', () => { ]) }) - it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => { + it('maps a failed cold inspection to one unavailable diagnostic and retries it next listing', async () => { const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'flaky storage') - const query = ctx.get('sessionQuery')! - const originalListEvents = query.listEvents.bind(query) - query.listEvents = (sessionId) => { - if (sessionId === childId) { - return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED')) + const healthy = await startChild(ctx, parent, 'healthy sibling') + const flaky = await authorChild(ctx, '00000000-0000-4000-8000-00000000f1a7', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('flaky storage'))) + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = (sessionId, signal) => { + if (sessionId === flaky) { + return Promise.reject(new Error('backend read failed')) } - return originalListEvents(sessionId) + return original(sessionId, signal) } - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }]) - }) - - it.each([ - ['session', 'SESSION_QUERY_SESSION_NOT_FOUND'], - ['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'], - ] as const)('maps a missing child %s to unavailable', async (_target, code) => { - const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'vanishing child') - const query = ctx.get('sessionQuery')! - query.listEvents = () => - Promise.reject(new SessionQueryError('gone', code)) - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }]) - }) - - it('maps an invalid child surface to corrupt', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'invalid surface') - const query = ctx.get('sessionQuery')! - query.listEvents = () => - Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE')) - - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }]) - }) - - it('diagnoses a read whose header no longer names this parent as corrupt', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'reparented child') - const query = ctx.get('sessionQuery')! - const originalReadEvent = query.readEvent.bind(query) - query.readEvent = async (request) => { - const window = await originalReadEvent(request) - return { - ...window, - session: { ...window.session, parentSession: SessionId('someone-else') }, - } - } - const entries = await ctx.subagents.listChildren(parent.id) - // The exact read's conflicting immutable header is per-child corruption. - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }]) - }) - - it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const childId = await startChild(ctx, parent, 'shifted log') - const query = ctx.get('sessionQuery')! - const originalReadEvent = query.readEvent.bind(query) - query.readEvent = async (request) => { - const window = await originalReadEvent(request) - return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target } - } - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }]) - }) - - it('fails the whole call when the initial trace fails', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'never listed') - const query = ctx.get('sessionQuery')! - query.traceSession = () => - Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED')) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error, - ) - }) - - it('propagates an unrecognized per-child failure as an operation failure', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'strange failure') - const query = ctx.get('sessionQuery')! - query.listEvents = () => Promise.reject(new Error('not a query failure')) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure') - }) - - it('propagates a configuration/window query failure instead of diagnosing the child', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'misconfigured query') - const query = ctx.get('sessionQuery')! - query.listEvents = () => - Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW')) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error, - ) + // Per-child isolation: the failed child degrades to one diagnostic while + // the healthy sibling stays complete. + const degraded = await ctx.subagents.listChildren(parent.id) + expect(degraded).toContainEqual({ kind: 'diagnostic', id: flaky, reason: 'unavailable' }) + expect(degraded).toContainEqual({ + kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) + // Nothing is memoized: with the backend healthy again, the next listing + // folds the same child to its identity. + ctx.sessionPersistence.inspect = original + await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({ + kind: 'child', id: flaky, label: 'flaky storage', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) }) it('lists compacted and uncompacted children identically', async () => { @@ -492,19 +437,18 @@ describe('SubagentService.listChildren', () => { ]) }) - it('reports an origin-classified grandchild without reading its events', async () => { + it('reports an origin-classified grandchild without inspecting it', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'direct child') const grandchildId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', { parentSession: childId, origin: 'subagent', }, childEvents(descriptorPayload('grandchild'))) - const query = ctx.get('sessionQuery')! - const originalListEvents = query.listEvents.bind(query) const inspected: SessionId[] = [] - query.listEvents = (sessionId) => { + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = (sessionId, signal) => { inspected.push(sessionId) - return originalListEvents(sessionId) + return original(sessionId, signal) } const entries = await ctx.subagents.listChildren(parent.id) expect(entries).toEqual([ @@ -513,6 +457,7 @@ describe('SubagentService.listChildren', () => { activity: 'inactive', hasChildren: true, }, ]) + // The grandchild contributes only its header to the hasChildren hint. expect(inspected).toContain(childId) expect(inspected).not.toContain(grandchildId) }) @@ -550,115 +495,92 @@ describe('SubagentService.listChildren', () => { }]) }) - it('stops the scan at the between-candidates checkpoint when the signal aborts', async () => { - const { ctx, parent } = await setup([textResponse('one'), textResponse('two')]) - await startChild(ctx, parent, 'first child') - await startChild(ctx, parent, 'second child') - const controller = new AbortController() - const query = ctx.get('sessionQuery')! - const originalListEvents = query.listEvents.bind(query) - let inspected = 0 - query.listEvents = (sessionId) => { - inspected += 1 - // Cancel while the first candidate's read is in flight: the loop's next - // between-candidates checkpoint must stop before the second read. - controller.abort() - return originalListEvents(sessionId) - } - await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow( - expect.objectContaining({ code: 'CANCELLED' }) as Error, - ) - expect(inspected).toBe(1) - }) - - it('forwards cancellation to the initial trace and reports the stable subagent error', async () => { + it('a pre-aborted signal stops before any persistence read', async () => { const { ctx, parent } = await setup([]) const controller = new AbortController() - const query = ctx.get('sessionQuery')! - const entered = Promise.withResolvers<undefined>() - query.traceSession = (_sessionId, signal) => { - entered.resolve(undefined) - return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { - reject(new Error('query trace aborted')) - }, { once: true }) - }) - } - const listing = ctx.subagents.listChildren(parent.id, controller.signal) - await entered.promise controller.abort() - await expect(listing).rejects.toThrow( - expect.objectContaining({ code: 'CANCELLED' }) as Error, - ) - }) - - it('forwards cancellation to the exact descriptor read and reports the stable subagent error', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'cancelled exact read') - const controller = new AbortController() - const query = ctx.get('sessionQuery')! - const entered = Promise.withResolvers<undefined>() - query.readEvent = (_request, signal) => { - entered.resolve(undefined) - return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { - reject(new Error('query read aborted')) - }, { once: true }) - }) - } - const listing = ctx.subagents.listChildren(parent.id, controller.signal) - await entered.promise - controller.abort() - await expect(listing).rejects.toThrow( - expect.objectContaining({ code: 'CANCELLED' }) as Error, - ) - }) - - it('stops after a per-child read when the signal aborts mid-inspection', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'cancelled mid-read') - const controller = new AbortController() - const query = ctx.get('sessionQuery')! - const originalReadEvent = query.readEvent.bind(query) - let exactReads = 0 - query.readEvent = async (request) => { - exactReads += 1 - const window = await originalReadEvent(request) - controller.abort() - return window - } - // The post-read checkpoint throws a subagent error, which is not a - // session-query failure and therefore propagates instead of becoming a - // per-child diagnostic. - await expect(ctx.subagents.listChildren(parent.id, controller.signal)) - .rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error) - expect(exactReads).toBe(1) - }) - - it('a mapped per-child failure during an abort cannot become a successful result', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'aborted behind a diagnostic') - const controller = new AbortController() - const query = ctx.get('sessionQuery')! - query.listEvents = () => { - // The read fails with a diagnostic-mapped code while the caller aborts: - // cancellation normalization must fail the scan rather than return a - // one-diagnostic success. - controller.abort() - return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED')) - } + ctx.sessionPersistence.list = () => Promise.reject(new Error('must not be called')) await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow( expect.objectContaining({ code: 'CANCELLED' }) as Error, ) }) - it('a pre-aborted signal stops before any candidate read', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - await startChild(ctx, parent, 'never read') + it('forwards cancellation to the persisted listing and reports the stable subagent error', async () => { + const { ctx, parent } = await setup([]) const controller = new AbortController() + const entered = Promise.withResolvers<undefined>() + ctx.sessionPersistence.list = (signal) => { + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(new Error('backend listing aborted')) + }, { once: true }) + }) + } + const listing = ctx.subagents.listChildren(parent.id, controller.signal) + await entered.promise controller.abort() - const query = ctx.get('sessionQuery')! - query.listEvents = () => Promise.reject(new Error('must not be called')) + await expect(listing).rejects.toThrow( + expect.objectContaining({ code: 'CANCELLED' }) as Error, + ) + }) + + it('forwards cancellation to a cold inspection and reports the stable subagent error', async () => { + const { ctx, parent } = await setup([]) + await authorChild(ctx, '00000000-0000-4000-8000-00000000ce11', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('cancelled cold read'))) + const controller = new AbortController() + const entered = Promise.withResolvers<undefined>() + ctx.sessionPersistence.inspect = (_sessionId, signal) => { + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(new Error('backend read aborted')) + }, { once: true }) + }) + } + const listing = ctx.subagents.listChildren(parent.id, controller.signal) + await entered.promise + controller.abort() + await expect(listing).rejects.toThrow( + expect.objectContaining({ code: 'CANCELLED' }) as Error, + ) + }) + + it('an abort observed after a cold inspection resolves cannot become a successful result', async () => { + const { ctx, parent } = await setup([]) + await authorChild(ctx, '00000000-0000-4000-8000-00000000ce12', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('cancelled mid-listing'))) + const controller = new AbortController() + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = async (sessionId, signal) => { + const result = await original(sessionId, signal) + controller.abort() + return result + } + // The post-read checkpoint throws the stable subagent error instead of + // interpreting the fully-read log as a successful listing. + await expect(ctx.subagents.listChildren(parent.id, controller.signal)) + .rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error) + }) + + it('a cold inspection failure during an abort cannot become an unavailable diagnostic', async () => { + const { ctx, parent } = await setup([]) + await authorChild(ctx, '00000000-0000-4000-8000-00000000ce13', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('aborted behind a failure'))) + const controller = new AbortController() + ctx.sessionPersistence.inspect = () => { + // The read fails while the caller aborts: cancellation normalization + // must fail the listing rather than return a one-diagnostic success. + controller.abort() + return Promise.reject(new Error('backend read failed')) + } await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow( expect.objectContaining({ code: 'CANCELLED' }) as Error, ) @@ -671,9 +593,9 @@ describe('SubagentService.listChildren', () => { }) it('SubagentError from listChildren is typed with its stable code', async () => { - const { ctx, parent } = await setup([], { sessionQuery: false }) + const { ctx, parent } = await setup([], { sessionProjections: false }) const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error) expect(caught).toBeInstanceOf(SubagentError) - expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE') + expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') }) }) diff --git a/packages/subagent/subagent/tests/optional-session-query.spec.ts b/packages/subagent/subagent/tests/optional-session-query.spec.ts deleted file mode 100644 index 469087e576..0000000000 --- a/packages/subagent/subagent/tests/optional-session-query.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -describe('@deepseek-ai/dsh-subagent optional session-query peer', () => { - it('loads ordinary subagent operations without evaluating the optional query package', async () => { - vi.doMock('@deepseek-ai/dsh-session-query', () => { - throw new Error('optional session-query runtime was loaded eagerly') - }) - - const subagent = await import('../src/index.ts') - - expect(subagent.SubagentService).toBeTypeOf('function') - }) -}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 612330c646..5bb065571f 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../session-persistence/session-persistence" }, - { - "path": "../../session-query/session-query" - }, { "path": "../../session-projection/session-projection" }, diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index d6bd2d78b9..f26a290f5e 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md -README.md: 5d775a524c38750953c6389b9ebdea67a33df7ca -README.zh.md: 3b989fca8b79cea3e3b10bb2e65805e0cee79c69 +README.md: ea95a45b85e01d1f5f1c478a35c80c65151724ac +README.zh.md: 2cc876c8b39caa19fdf30eae7c8def0ba81fe7b1 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 5d775a524c..ea95a45b85 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents`, declares `sessionQuery` as a load-time dependency, and remains inactive until that service is available. A deployment without session query keeps `send_message` and omits the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction. +The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and the separately loadable `./list-agents` plugin registers `list_agents`; both require only `subagents`, so a deployment can keep `send_message` while omitting the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction. The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. This call returns no child reply — its transcript by that id is the source of what it did — and a child with `report` sends content on its own initiative as a separate parent message. A delivery failure becomes an errored tool result stating the message was not delivered. diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 3b989fca8b..2cc876c8b3 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,将 `sessionQuery` 声明为加载时依赖,并在该服务可用前保持未激活状态。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。 +可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,可单独加载的 `./list-agents` 插件注册 `list_agents`;两者都只要求 `subagents`,部署可保留 `send_message` 而省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。 本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。 diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index f0d57c52aa..3a650db8fa 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -33,16 +33,10 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, - "peerDependenciesMeta": { - "@deepseek-ai/dsh-session-query": { - "optional": true - } - }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -52,7 +46,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/tool-subagent-control/src/list-agents.ts b/packages/subagent/tool-subagent-control/src/list-agents.ts index 75f9cbe450..bab3fb6f40 100644 --- a/packages/subagent/tool-subagent-control/src/list-agents.ts +++ b/packages/subagent/tool-subagent-control/src/list-agents.ts @@ -1,20 +1,17 @@ /** * The globally named `list_agents` tool: a thin model-facing adapter over - * the continuable projection of `ctx.subagents.listChildren()`. It is - * separately loadable from the - * root `send_message` plugin because it additionally requires the session - * query service — a deployment may use `send_message` without loading session - * query, and this plugin remains inactive until that service is available. + * the continuable projection of `ctx.subagents.listChildren()`. It stays + * separately loadable from the root `send_message` plugin so a deployment + * can register `send_message` without exposing the list tool. * @module @deepseek-ai/dsh-tool-subagent-control/list-agents */ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-subagent' export const name = 'tool-subagent-list-agents' -export const inject = ['tools', 'subagents', 'sessionQuery'] +export const inject = ['tools', 'subagents'] type ListAgentsEntry = | { @@ -31,7 +28,7 @@ type ListAgentsEntry = /** * Register the `list_agents` tool. - * @param ctx - context carrying the tool registry, subagent service, and session query. + * @param ctx - context carrying the tool registry and subagent service. */ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 9872f73456..217d388fb4 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -12,7 +12,6 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts' import * as tool from '../src/list-agents.ts' const testToolSignal = new AbortController().signal @@ -31,7 +30,6 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) - await ctx.plugin(TestSessionQueryService) await ctx.plugin(tool) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) @@ -177,17 +175,16 @@ describe('dsh-tool-subagent-control/list-agents', () => { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(TestSessionQueryService) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true) await fiber.dispose() expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false) }) - it('has the namespace-plugin export shape and requires sessionQuery at load', () => { + it('has the namespace-plugin export shape', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-list-agents') - expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery']) + expect(tool.inject).toEqual(['tools', 'subagents']) expect(typeof tool.apply).toBe('function') }) }) diff --git a/packages/subagent/tool-subagent-control/tsconfig.json b/packages/subagent/tool-subagent-control/tsconfig.json index 91eeb707b0..3a57a0437e 100644 --- a/packages/subagent/tool-subagent-control/tsconfig.json +++ b/packages/subagent/tool-subagent-control/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../subagent" }, - { - "path": "../../session-query/session-query" - }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b826046bea..f1703d3ef7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5190,9 +5190,6 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../session-query/session-query '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../tasks/tasks @@ -5590,9 +5587,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../session-query/session-query '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent From 0b0b9e47070936767a6251b09118d168d3f8cab3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:41:53 +0800 Subject: [PATCH 158/190] fix: address PR #1802 review round - listChildren reads the session store via strict ctx.get (property proxy is caller-scoped), orders candidates branchlessly, narrows the cold-read return type, and pins the cost model and store/registry composition gaps with tests; per-file coverage restored - acp-agent and headless-agent compositions mount session-projection; a keyless snapshot pins the descriptor-less diagnostic row - api-proxy cold spec pins header-origin ownership and the legacy descriptor-only opt-out - design note ships as implemented with its English pairing; companion notes and core-data-structures pages synced --- ...ubagent-list-identity-projection.i18n.yaml | 6 + ...08-06-subagent-list-identity-projection.md | 177 ++++++++++++++++++ ...06-subagent-list-identity-projection.zh.md | 96 ++++------ ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 2 + ...ble-subagent-catalog-and-list-agents.zh.md | 2 + ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 4 + ...7-session-projection-and-command-log.zh.md | 4 + docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 8 +- docs/core-data-structures/subagent.zh.md | 8 +- examples/acp-agent/cordis.yml | 6 + examples/headless-agent/cordis.yml | 5 + .../subagent-diagnostic.cordis.snapshot.yml | 44 +++++ .../fixtures/subagent-diagnostic-agent.ts | 26 +++ .../parent.expected.jsonl | 31 +++ .../descriptorless-child/replay.override.json | 1 + .../tests/subagent-diagnostic.snapshot.ts | 119 ++++++++++++ examples/package.json | 1 + .../apiproxy/tests/api-proxy-cold.spec.ts | 42 +++++ packages/subagent/subagent/src/index.ts | 4 +- .../subagent/subagent/src/list-children.ts | 23 ++- packages/subagent/subagent/src/projection.ts | 8 +- .../subagent/tests/list-children.spec.ts | 102 ++++++++-- .../tool-subagent-control/package.json | 1 + .../tests/list-agents.spec.ts | 2 + .../tests/tool-subagent-control.spec.ts | 2 + pnpm-lock.yaml | 6 + 29 files changed, 637 insertions(+), 105 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md rename .agents/notes/{proposed => implemented}/architecture/2026-08-06-subagent-list-identity-projection.zh.md (50%) create mode 100644 examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts create mode 100644 examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl create mode 100644 examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json create mode 100644 examples/headless-agent/tests/subagent-diagnostic.snapshot.ts diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml new file mode 100644 index 0000000000..4620ec99c9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.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/architecture/2026-08-06-subagent-list-identity-projection.md +2026-08-06-subagent-list-identity-projection.md: 6b6ee863bf385e27f4c431f7a1039ea75110565e +2026-08-06-subagent-list-identity-projection.zh.md: 42a578147026ae8d09669d13ada468a4491dcb37 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md new file mode 100644 index 0000000000..6b6ee863bf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -0,0 +1,177 @@ +# Agent Note: Subagent list identity via the projection unit + +Status: implemented + +English | [中文](2026-08-06-subagent-list-identity-projection.zh.md) + +## Problem + +Before the rewrite, `SubagentService.listChildren` ran two full-log materializations — `listEvents` plus `readEvent` — on every listing for each direct child with `header.origin === 'subagent'`, each materialization accompanied by a full-log structuredClone, all to fold two fields, mode and label, out of the descriptor event. The descriptor's position in the log is not fixed — the fork prefix is arbitrarily long, and zstd-compressed frames carry no seq index — so there is no shortcut to locating it; this path had no cache whatsoever, and its cost amplifies with transcript length × child count × listing frequency. It also dragged session-query in as a hard dependency of listing: in a deployment without a query backend, `list_agents` rejects wholesale with `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`, even though enumeration needs nothing but header facts. + +The same root cause has a second symptom: on every Agent-bound RPC's owner check, the host-side `hasSubagentDescriptor()` scans the target session's own suffix, even though `SessionHeader.origin` already answers the vast majority of the same question. + +The root cause is that the [durable-subagent-catalog decision](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) made the descriptor event (`subagent/descriptor`) the catalog's sole durable authority yet paired descriptor reads with no cache layer, and explicitly accepted the per-child double read as the "no-index correctness baseline". [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) (#1569) already put "is this a subagent" into the header (`SessionHeader.origin`), so identity determination no longer reads the log; mode and label still had to be scanned. + +## Decision + +mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a two-tier live/cold compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads), and a cold child pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache, no write-back. + +There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was once this note's settled direction and was under construction for a time, then retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered. + +Key points: + +- **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual. +- **Value retrieval is a two-tier compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache, no write-back, no index. +- **The `subagent` projection unit is the sole authority over the fold rules**: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists. +- **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration. + +Relationship to existing notes: + +- This note supersedes two designs on the list read path in [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md): enumeration through `sessionQuery.traceSession`, and per-child descriptor-event reads (the `listEvents`-plus-exact-`readEvent` double read with in-place diagnostic classification). The diagnostic row semantics is retained, with classification now derived by the list from projection-value absence and activity; the descriptor event remains the sole durable authority for mode/label and the fold input, and the resume authorization and Activation contracts are untouched. This is partial supersession; the two notes stay cross-linked. +- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)'s registry contract (`ProjectionDefinition`, `snapshot`, `restore`) is untouched; this note only adds one registration to it — the `subagent` identity unit — and becomes another consumer instance of the two existing reads, snapshot (live) and restore (cold) — GUI history's cold read is already the same shape. The fold rules are registered with the registry exactly once; every consuming surface computes through the registry, and no second copy of the fold logic exists. + +### `subagent` projection unit + +It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`: + +```ts ignore-check +export type SubagentIdentityProjection = + | { mode: 'one-shot'; label?: string } + | { mode: 'continuable'; label: string } + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + subagent: SubagentIdentityProjection + } +} +``` + +- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is "no value", and the key is absent on that session. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). +- Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; this discriminant matches the child row's strong mode/label contract below exactly. +- Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to no value rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. + +### Enumeration: subagent-owned live-preferred merge + +`listChildren`'s ([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts)) enumeration goes through no query service: the two sources `ctx.sessions.list()` and `ctx.get('sessionPersistence')?.list()` merge by id, with a live record overriding the same-id persisted record wholesale and no header consistency check. Everything enumeration needs is header facts: + +- Filtering: `header.origin === 'subagent' && header.parentSession === parentSessionId`. +- `hasChildren`: the same merged material, looked at one level down — a direct descendant exists with `origin === 'subagent'` whose `parentSession` is that child. +- `activity`: a live record is `running`; one present only in persistence is `inactive`. +- Ordering: `createdAt` ascending, then child id ascending (matching the old contract). +- **Absent persistence degrades to live-only enumeration, not an error**: in a deployment without persistence, a cold child could not be resumed anyway, and listing live children remains meaningful. (Contrast: the old implementation rejected wholesale when sessionQuery was missing.) +- A persistence listing failure fails the whole enumeration; per-child isolation applies only to the per-child cold reads. + +### Value retrieval: the two-tier compute-and-discard ladder + +For each enumerated child, mode/label retrieval walks a two-tier ladder, the same shape as apiproxy `session.history`'s cold read — compute-and-discard, no cache, no write-back: + +| Tier | Read | Cost | +| --- | --- | --- | +| live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | +| cold child | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing | + +- Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. +- Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping). +- Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field. +- The cold-read cost, recorded honestly: a cold child pays one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache is built for it. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout. +- Cancellation: the caller's signal is checked before and after each persistence read, and a read that settles only after abort is rejected, normalized to the stable error code `CANCELLED`. + +### Authority model + +- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints, no in-process memo. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read. +- The Session and persistence write paths are entirely unaware of listing and projection consumption: no event-listener write-back, no fold-on-write. +- Enumeration and value retrieval constitute no second authorization source and make no unpublished child visible — the two sources see only published live records and durably written persisted records, consistent with the rule the durable-subagent-catalog note laid down for derived read surfaces. + +### `listChildren` row shape and consuming surfaces + +The `SubagentListEntry` **data structure is identical to before the rewrite** — the child and diagnostic arms, the `kind` discriminant, the three-valued `reason`, and the child arm's strong mode/label contract are all retained; the only change is the diagnostics' information source: the projection system has no failure channel, so diagnostics are derived by the list from projection-value absence and activity, and the list itself parses zero events. The "no value means await the hard read" rule guarantees the ladder always computes mode/label for healthy data. + +```ts ignore-check +export type SubagentListEntry = + | ({ + readonly kind: 'child' + readonly id: SessionId + readonly activity: 'running' | 'inactive' + readonly hasChildren: boolean + } & ( + | { readonly mode: 'one-shot'; readonly label?: string } + | { readonly mode: 'continuable'; readonly label: string } + )) + | { + readonly kind: 'diagnostic' + readonly id: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } +``` + +For each enumerated child, the ladder's result maps to a row through four states: + +| Ladder result | Row | +| --- | --- | +| Snapshot carries a `subagent` value | child row | +| Snapshot present, value absent, and the child is **inactive** | diagnostic row, reason `corrupt` (settled debris: a missing, corrupt, or unrecognized-version descriptor, no longer subdivided) | +| Snapshot present, value absent, and the child is **running** | no row (creation window: the descriptor is not yet appended — the same window the old implementation omitted) | +| The cold full read fails | diagnostic row, reason `unavailable` | + +- `unsupported` is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced. +- Descriptor-less settled debris moves from the old implementation's omit into the `corrupt` diagnostic — damaged, dead child sessions in the corpus are visible rather than silently vanishing, which is exactly the original motivation for keeping diagnostics. + +Known boundary deviations (deliberately accepted, recorded with this note): + +- A fork child that died in its publication window, with an ancestor descriptor in its seed, gets the ancestor identity from last-wins and wrongly surfaces as a child row; resume still fails against the own-suffix fold authority (`NOT_RESUMABLE`). The old implementation omitted it via `seedLength` filtering; the projection unit cannot see the header, and this debris-grade deviation is accepted (`subagentTiming` has the same kind of pre-existing exposure). +- Multiple descriptors in the own suffix: the old implementation judged corrupt; last-wins now takes the final one (the provider contract guarantees exactly one anyway). +- A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. +- A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). + +Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral change is the apiproxy route segment: the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this. + +### Change footprint + +| Area | Files | Change | +| --- | --- | --- | +| subagent | projection.ts, projection-types.ts, index.ts | New `subagent` unit and its registration | +| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` | +| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin` | +| tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged | +| wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | **Zero changes** — row shape and diagnostic handling unchanged | +| core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** | + +## Alternatives considered + +**mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format. + +**The projection-cache ladder (v3 draft: `cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But it hands the subagent domain a `sessionProjectionCache` dependency on top of `sessionProjections`, and checkpoints are a new body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); read-time computation needs no durable derivation at all. + +**A bounded-read primitive on persistence to rescue pre-existing data.** Opens a new seam primitive for a one-time problem; superseded by the read-time `inspect` full read — the full read the first time pre-existing data is listed is itself the value retrieval. + +**Optional mode/label on list rows (one v4 draft).** Healthy data is always computable; optionality merely spills garbage-data handling complexity onto every consumer — each consuming surface has to grow filter branches and an unknown display state. The strong contract plus omit-when-uncomputable is cleaner. + +**Deleting diagnostic rows outright (one v5 draft).** Deletion turns corpus-corruption visibility into rows silently vanishing, and wire/tool/GUI would each have to absorb contract and snapshot changes; retention only asks the list side to derive the classification from projection-value absence and activity, at zero cost. That damaged, dead child sessions in the corpus must be visible is the original motivation for diagnostics' existence, and with retention the consuming surfaces stay wholly unchanged. + +**A registry computation failure channel (per-unit fault tolerance plus a supplementary `failures` field).** To report corruption and unrecognized versions to consumers, we once considered having the registry catch unit exceptions and attach a per-key failure state beside the snapshot. Rejected: a failure is not a value and needs no channel — a unit never throws, absence is itself the signal, worst case the computation comes back empty, and how that is presented is the consumer's problem. The discussion of this route left one independent observation behind: the vendored Cordis `emit` ([vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)) catches nothing a listener throws, so with the projection driver hanging off `session/event`, a unit exception would escape along emit — which adds weight to the "a unit never throws" discipline, but fixing emit fault tolerance is outside this note's scope. + +**Values landed with query index preparation (the v4/v5 settled design, built for a time).** Projection values folded into session index rows during the sqlite backend's reconciliation rebuild, for zero log reads in the steady read state; the `projectionsFor` bulk read face, the invalidation reconciliation of row values stored against the `(key → stateVersion)` registration set, and the SCHEMA bump were all actually built. Retired wholesale: the direction was backwards — query infrastructure was forced to learn domain vocabulary (projection columns, registration-set reconciliation) while the sole consumer, the subagent list, is satisfied by read-time computation; with consumers down to zero, this derived persistence has no reason to exist. `SESSION_QUERY_PROJECTIONS_UNAVAILABLE` was deleted along with the read face. + +**Subagent hand-rolled parsing plus an in-process memo plus creation seeding (v6 draft).** To excise the session-query dependency, we once considered the subagent package parsing descriptor events itself, avoiding repeated full reads with an in-process memo, and seeding initial values at creation. Superseded by the v7 ladder: live goes through the `sessionProjections` watermark cache and cold through `registry.restore`, reusing the registry's single fold authority — no second copy of descriptor-interpretation logic appears, and no process-state cache or seeding ordering is introduced. + +**DeepReadonly on the session-query output surface (a read-path overhaul experiment).** Make the public query outputs deeply readonly to pin immutable borrowing at the type level. Rejected on evidence: 3 TS2589 occurrences (excessively deep type instantiation) plus 17 sites of array-position contagion (consumers' array methods and spread sites forced to follow); deep immutability is guaranteed by core/session's runtime deep freeze, and that read-path overhaul is not part of this note. + +## Verification + +`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots. + +## Consequences + +- Listing a live child reads zero log throughout; a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it. +- The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`. +- Identity interpretation exists only in the single unit registered with the registry: the list's two-tier ladder and GUI history's cold read use the same two reads (snapshot/restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. +- Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. +- The diagnostic semantics leaves four boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, and damaged-source read failures shifting from `corrupt` to `unavailable`); the full semantics is in the known-boundary-deviations list; all are display or classification deviations on debris-grade data, and resume authorization is unaffected. +- Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. + +## Related + +- [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder. +- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit to it and becomes a consumer instance of the two existing reads, snapshot and restore. +- [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) — the origin of `SessionHeader.origin` (#1569), the first half of taking identity determination off the log; its history cold read (inspect prefix plus registry fold) is the same-shape precedent for this note's value ladder. +- [Reusable Session preparation before publication](2026-08-05-session-preparation.md) — the `inspect()` cold read and LRU reuse; the cold child's full-read cost model builds on it. diff --git a/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md similarity index 50% rename from .agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md rename to .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index c4239ca903..42a5781470 100644 --- a/.agents/notes/proposed/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -1,35 +1,34 @@ # Agent Note: subagent 列表经投影单元读取身份 -Status: proposed +Status: implemented [English](2026-08-06-subagent-list-identity-projection.md) | 中文 ## 问题 -`SubagentService.listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))对每个 `header.origin === 'subagent'` 的直接 child,每次列表都执行 `listEvents` 加 `readEvent` 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript 长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,`list_agents` 以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 整体拒绝,尽管枚举所需只是 header 事实。 +重写前的 `SubagentService.listChildren` 对每个 `header.origin === 'subagent'` 的直接 child,每次列表都执行 `listEvents` 加 `readEvent` 两次整日志物化,且每次物化都伴随整日志 structuredClone,只为从描述符事件里折出 mode 与 label 两个字段。描述符在日志中的位置不固定——fork 前缀任意长,zstd 压缩帧没有 seq 索引——因此定位没有捷径;这条路径没有任何缓存,代价随 transcript 长度 × child 数量 × 列表频率放大。它还把 session-query 拉成列表的硬依赖:没有 query backend 的部署,`list_agents` 以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 整体拒绝,尽管枚举所需只是 header 事实。 -同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()`([api-proxy.ts](../../../../packages/host/apiproxy/src/api-proxy.ts))在每次 Agent 绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 +同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()` 在每次 Agent 绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 -根因在于 [durable-subagent-catalog 决策](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为"无索引的正确性基线"。[web subagent conversations](../../implemented/feature/2026-07-27-web-subagent-conversations.md)(#1569)已把"是不是 subagent"放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 +根因在于 [durable-subagent-catalog 决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为"无索引的正确性基线"。[web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)(#1569)已把"是不是 subagent"放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 -## 提案 +## 决策 -mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 摘除 session-query 依赖——枚举由 subagent 自管的 live-preferred 合并完成,取值走 live/cold 两级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读),cold child 一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、无缓存、无回写。 +mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走 live/cold 两级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读),cold child 一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、无缓存、无回写。 消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。"值随查询索引落库"曾是本记录的定稿方向并一度施工,最终整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被"算完即止"显式接受。前两条与退役理由详见考虑过的替代方案一节。 -方案要点: +要点: -- **subagent 列表不再依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 +- **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 - **取值两级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——无缓存、无回写、无索引。 - **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 -- **session-query 的净变化只剩读路径去 clone 加浅 readonly 借用视图**(附带工作项;DeepReadonly 被实证否决,见替代方案)。 -- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query-sqlite 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 +- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 与既有记录的关系: -- 本记录取代 [durable-subagent-catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活契约不动。属部分取代,两记录保持交叉链接。 -- [session-projection RFC](2026-07-27-session-projection-and-command-log.md) 的 registry 契约(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。 +- 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活契约不动。属部分取代,两记录保持交叉链接。 +- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) 的 registry 契约(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。 ### `subagent` projection unit @@ -49,17 +48,18 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果就是"无值",该 key 在这个 session 上缺席。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 - label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;该判别式与下文 child 行的 mode/label 强契约完全一致。 -- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。 +- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为无值而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 ### 枚举:subagent 自管 live-preferred 合并 -`listChildren` 的枚举不再经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 优先、不做一致性校验。枚举所需全部是 header 事实: +`listChildren`([list-children.ts](../../../../packages/subagent/subagent/src/list-children.ts))的枚举不经任何查询服务:`ctx.sessions.list()` 与 `ctx.get('sessionPersistence')?.list()` 两个来源按 id 合并,live 记录整条覆盖同 id 持久化记录、不做 header 一致性校验。枚举所需全部是 header 事实: - 过滤:`header.origin === 'subagent' && header.parentSession === parentSessionId`。 - `hasChildren`:同一份合并材料向下看一层——存在 `origin === 'subagent'` 且 `parentSession` 为该 child 的直接后代。 - `activity`:live 记录为 `running`,仅存在于持久化的为 `inactive`。 - 排序:`createdAt` 升序、再按 child id 升序(与旧契约一致)。 - **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。) +- persistence 列表失败使整次枚举失败;per-child 隔离只作用于逐 child 的冷读。 ### 取值:两级"算完即止"阶梯 @@ -70,9 +70,11 @@ declare module '@deepseek-ai/dsh-session-projection/types' { | live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | | cold child | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 | -- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 随 session-query 依赖一并删除。 -- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,不影响 sibling(见四态映射)。 -- 冷读成本如实记录:cold child 每次列表一次整读,成本与其 transcript 大小成正比;定案"算完即止",不为它建缓存。整读经 `inspect()` 走 [Session 准备阶段](../../implemented/architecture/2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 +- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 +- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。 +- 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。 +- 冷读成本如实记录:cold child 每次列表一次整读,成本与其 transcript 大小成正比;定案"算完即止",不为它建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 +- 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。 ### 权威模型 @@ -82,7 +84,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { ### `listChildren` 行形状与消费面 -`SubagentListEntry` **数据结构与今天完全一致**——child 与 diagnostic 两臂、`kind` 判别、reason 三值、child 臂的 mode/label 强契约全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身仍零事件读取。"没有就等待硬读取"继续保证阶梯对健康数据必然算得出 mode/label。 +`SubagentListEntry` **数据结构与重写前完全一致**——child 与 diagnostic 两臂、`kind` 判别、reason 三值、child 臂的 mode/label 强契约全部保留;变化只在诊断的信息来源:投影体系没有失败通道,diagnostic 由列表按投影值缺席与 activity 派生,列表本身零事件解析。"没有就等待硬读取"保证阶梯对健康数据必然算得出 mode/label。 ```ts ignore-check export type SubagentListEntry = @@ -102,8 +104,6 @@ export type SubagentListEntry = } ``` -实现形态:`listChildren` = 自管枚举(id、activity、hasChildren、`origin` 过滤,全部来自 header 事实)+ 投影阶梯(mode/label)。逐 child 的 `listEvents`、精确 `readEvent`、描述符定位与就地分类机器整体删除。 - 对每个枚举出的 child,阶梯取值结果按四态映射成行: | 阶梯取值结果 | 行 | @@ -123,35 +123,18 @@ export type SubagentListEntry = - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 -消费面:wire、tool、GUI 的 diagnostic 处理**全部保持现状零改动**(`list_agents` 的 description 与 output schema 亦不动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。唯一动行为的是 apiproxy 路由段:删 `hasSubagentDescriptor()` 扫描,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 +消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上唯一动的是 apiproxy 路由段:`hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 -### 附带工作项:session-query 读路去 clone 与浅 readonly - -- `SessionCorpus.load()`、`snapshotLive`、`listSessions` 等移除 structuredClone:live Session 的事件快照数组与事件载荷已深冻结(core/session 的 `deepFreeze` 加 `Object.freeze`),持久化读出的对象图为独占新建,克隆纯属浪费。 -- 公开查询输出标注**浅 readonly**(顶层属性与数组位);深只读化被实证否决(见替代方案),深层不可变由 core/session 的运行时深冻结事实保证,类型层面不再表达,`DeepReadonly` 不进任何公共包。 -- 契约措辞与 `projectMany` 的借用契约("borrowed only for that call")对齐:整个 corpus 面向消费方统一为"只读视图,不得留存可变引用"的不可变借用视图;需要留存的自行克隆。 - -### 改动面清单 +### 改动落点 | 区域 | 文件 | 改动 | | --- | --- | --- | | subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 | | subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` | -| session-query | index.ts、corpus.ts | 读路径去 clone,公开输出浅 readonly 借用视图(净变化仅此) | | host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin` | | tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | | wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | **零改动**——行形状与 diagnostic 处理不变 | -| core/session、session-persistence、session-projection(-cache)、session-query-sqlite | — | **零改动** | -| 测试/快照 | 相关 spec 与 snapshot | 随行为更新,提 PR 前统一处理 | - -### 推进节奏 - -1. `subagent` projection unit 与注册(纯增量)。 -2. session-query:corpus 去 clone 与浅 readonly 借用视图。 -3. `listChildren` 重写(自管枚举 + 投影阶梯);tool 加载要求收窄;apiproxy 路由段 `hasSubagentDescriptor` 删除。 -4. 测试与快照统一更新,整体 diff 评审后再拆 commit。 - -配套文档随实现 PR 处理:[session-projection RFC](2026-07-27-session-projection-and-command-log.md) 增补一节,记录 `subagent` 身份 unit 与 snapshot/restore 两处既有读法的消费实例(registry 契约零改动);[durable-subagent-catalog 记录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的列表读路径段落随实现更新并与本记录交叉链接。 +| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** | ## 考虑过的替代方案 @@ -171,29 +154,24 @@ export type SubagentListEntry = **subagent 手工 parse 加进程 memo 加创建播种(v6 稿)。** 为摘除 session-query 依赖,曾考虑 subagent 自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被 v7 阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走 `registry.restore`,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。 -**session-query 输出面 DeepReadonly(去 clone 一稿)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);退回浅 readonly,深层不可变由 core/session 的运行时深冻结保证。 +**session-query 输出面 DeepReadonly(读路径改造实验)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);深层不可变由 core/session 的运行时深冻结保证,该读路径改造未纳入本记录。 -## 验收标准 +## 验证 -- 稳态列表读代价:live child 全程零 events 读取(仅注册表水位缓存);cold child 每次 `listChildren` 恰一次 `persistence.inspect` 整读;由 subagent 测试断言。 -- 行为等价:同一语料下,新实现产出与旧实现相同的行集合(child 行的 id、mode、label、activity、hasChildren 与 diagnostic 行的 id、reason),例外仅限本记录留档的语义变化——descriptor-less 定局残骸由 omit 改为 `corrupt` 行、`unsupported` 归并入 `corrupt`、四条边界偏差(stillborn fork 祖先身份、多描述符 last-wins、header 冲突不再察觉、损坏源读失败由 `corrupt` 转 `unavailable`)——且每处变化有测试钉住新行为。 -- 四态映射成立:快照有值成 child 行;inactive 缺值产生 `corrupt` 行(含 descriptor-less 定局残骸);running 缺值缺席(创建窗口);cold 整读失败映射 `unavailable`;`unsupported` 不再产出。 -- 错误契约:`ctx.sessionProjections` 未挂载时 `listChildren` 于枚举前以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 失败(零 children 部署同样确定失败);`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 从代码与文档中消失。 -- persistence 缺席退为 live-only 枚举,不报错,live child 照常成行。 -- per-child 隔离:单 child 整读失败只产生该行 `unavailable`,sibling 不受影响。 -- `hasSubagentDescriptor` 删除后属主判定只认 `header.origin`;`list_agents` 的 description、output schema 与既有无密钥快照零变化,钉住 wire/tool/GUI 零改动。 -- corpus 去 clone 后公开输出为浅 readonly 借用视图,既有 session-query 行为测试全数通过。 +`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 -## 风险 +## 后果 -- **折叠规则分叉。** "折叠只在 registry 一份"是本设计的承诺;若未来某消费面绕开 registry 手写折叠,各读面的值可能漂移。缓解:列表两级阶梯与 GUI history 冷读走的都是 registry 的同两处读法(snapshot/restore),不存在旁路折叠。 -- **cold child 的每次列表整读成本。** cold child 每次 `listChildren` 都做一次 `inspect` 整读现算,成本与其 transcript 大小成正比、随列表频率重复;定案"算完即止",不建缓存、不回写。同 id 短期重复整读可命中持久化协调器准备阶段的 LRU 复用,但列表不依赖它;live child 全程零读。显式接受。 -- **诊断语义的四处边界偏差。** stillborn fork 的祖先身份误现为 child 行、多描述符改取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`——完整语义与接受理由见提案的已知边界偏差清单。均为残骸级数据的展示或分类偏差,恢复鉴权不受影响。 -- **pre-#1569 存量属主判定收窄。** 无 `origin` 的旧 child 不再被认作 subagent 属主。其本就不进目录,pre-release 无兼容承诺,接受。 +- live child 的列表全程零日志读;cold child 每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案"算完即止",不建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 +- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。 +- 身份解释只存在于 registry 注册的一份 unit:列表两级阶梯与 GUI history 冷读走同两处读法(snapshot/restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 +- per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 +- 诊断语义留下四处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`),完整语义见已知边界偏差清单;均为残骸级数据的展示或分类偏差,恢复鉴权不受影响。 +- pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 ## 相关 -- [durable-subagent-catalog 与 list_agents](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 -- [session projections 与命令生命周期日志](2026-07-27-session-projection-and-command-log.md)——registry 契约的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。 -- [web subagent conversations](../../implemented/feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 -- [发布前可复用的 Session 准备阶段](../../implemented/architecture/2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 +- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 +- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)——registry 契约的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。 +- [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 +- [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index aefda46d41..74932324dd 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: 0dd7eebac74689004014248c7178dba540ef4662 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 33b0296cf9914d1975fb1dc84564b498a09bd511 +2026-07-22-durable-subagent-catalog-and-list-agents.md: 1de93cc1374e8e86bace6af94b51efe94b38f89a +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: fe5422c497b87bb39d43ac97cb5d1a9bed9fcbfb diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 0dd7eebac7..1de93cc137 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -12,6 +12,8 @@ Enumeration must cross-check immutable session lineage, descriptor validity, and ## Decision +**Superseded read path.** [Subagent list identity via the projection unit](../architecture/2026-08-06-subagent-list-identity-projection.md) replaces this note's enumeration and per-child read design: `listChildren` now merges the live session store with optional session persistence directly and serves each child's mode/label from the registered `subagent` projection unit — no session-query dependency, no list-time descriptor scan — and that note owns the current listing semantics, including the diagnostic mapping. This note remains the authority for descriptor persistence, the mode-discriminated descriptor as durable identity, direct-parent authorization, and the model-facing `list_agents` projection; the trace-based read mechanics below are decision context, not current behavior. + Parent-to-child enumeration is a service capability with consumer-specific projections. `SubagentService.listChildren(parentSessionId: SessionId)` ([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts)) does the following: - use `ctx.sessionQuery.traceSession(parentSessionId)` to obtain the parent's direct live-preferred child sessions; diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index 33b0296cf9..fe5422c497 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -12,6 +12,8 @@ Status: implemented ## 决策 +**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。 + parent 到 child 的枚举是一项带消费方专用投影的服务功能。`SubagentService.listChildren(parentSessionId: SessionId)`([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts))执行以下操作: - 使用 `ctx.sessionQuery.traceSession(parentSessionId)` 获取 parent 的直接且实时优先的 child 会话; diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 5fabe8a942..1c070a03a2 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-07-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5 -2026-07-27-session-projection-and-command-log.zh.md: a22ebe57811339a0e583ae00909e60482ddb57b1 +2026-07-27-session-projection-and-command-log.md: 789e79f2ecab1a9f3ac717df86059150ed2d4da9 +2026-07-27-session-projection-and-command-log.zh.md: 4d680b37f5d49a243447542706c8b7ced8d80e2a diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 6a073c956c..789e79f2ec 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -56,6 +56,10 @@ declare module 'cordis' { - Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. - The package owns `./invariant` (every served key has a live registration). +### Shipped consumer: the subagent identity unit + +The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md) registers a `subagent` unit — the durable mode/label identity folded last-wins from `subagent/descriptor` — and `SubagentService.listChildren` reads it through `snapshot()` for a live child (the watermark cache, zero log reads) and `restore({}, events, 0)` over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision. + ### Wire: projections block on the history tail page ```ts ignore-check diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index a22ebe5781..4d680b37f5 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -56,6 +56,10 @@ declare module 'cordis' { - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。 +### 已交付的消费方:subagent 身份单元 + +注册表的两处读面已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md)注册了 `subagent` 单元——从 `subagent/descriptor` 以 last-wins 折叠出的持久 mode/label 身份——`SubagentService.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 经一次持久化检查上的 `restore({}, events, 0)` 读取。注册表契约不变:没有失败通道、没有新读面——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。 + ### 协议层:历史尾页上的 projections 块 ```ts ignore-check diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index a2d1e76928..a18deee8eb 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: 315051fafaa0bb291a0f7525d2de142d8570961b -subagent.zh.md: 5e147b85b1b9a57fb604145bef66e560c443c1de +subagent.md: e364572f9ac6a52acb118de0906cb5ec442536cc +subagent.zh.md: 4440c4f6a4212d0cf8a4d6367389f6973dfc5d17 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 315051fafa..e364572f9a 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`, `-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). +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 straight from the session store and optional session persistence. 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), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.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) @@ -248,11 +248,11 @@ interface ContinuableCreateSpec { The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. It never snapshots the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (one run or Activation's result contract, not durable identity). -A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary, so descriptor lookup reads the child's own suffix. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. +A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the list-serving identity projection folds `subagent/descriptor` last-wins so the child's own descriptor overrides a fork-seeded ancestor's. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. ## Durable enumeration: `listChildren()` and `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from one `ctx.sessionQuery.traceSession()` observation, without loading or resuming any Agent. Session lineage is broader than subagent identity — ordinary forks share `parentSession` — so exactly one supported `subagent/descriptor` event in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) is the sole subagent discriminator. `SessionHeader.origin: 'subagent'` is only a coarse product-navigation classifier stamped before publication; it can suppress duplicate sidebar rows but cannot establish a valid descriptor, resumability, or authorization. The result is one `SubagentListEntry[]` in the trace's `createdAt`-then-id candidate order: a valid descriptor yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A per-child inspection failure yields a `diagnostic` entry (`corrupt`, `unsupported`, or `unavailable`) so one damaged sibling cannot hide healthy children; a missing descriptor yields no entry. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. A failure while building the initial trace fails the whole call — per-child isolation begins only after a trustworthy candidate set exists. The service keeps `sessionQuery` optional for by-id continuation: `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` when it is absent, while the list tool requires `ctx.subagents` and `ctx.sessionQuery` at plugin load. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. +`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served from the registry's watermark cache for a live child (zero log reads) and folded once over one `persistence.inspect()` reading for a cold one (bounded concurrency, recomputed per listing — no cache). The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent, checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). ## The terminal result: `SubagentResult` @@ -344,7 +344,7 @@ interface SubagentRun { } ``` -A local one-shot run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, record `request.parent.session.id` in the child's `parentSession` header, and append the resolved descriptor inside the child's initial turn before its first request. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`; without a local child Session, it is absent from trace-backed enumeration. +A local one-shot run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, record `request.parent.session.id` in the child's `parentSession` header, and append the resolved descriptor inside the child's initial turn before its first request. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`; without a local child Session, it is absent from durable enumeration. ## The provider seam: `SubagentProvider` diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 5e147b85b1..4440c4f6a4 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`、`-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)。 +接口:[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/architecture/2026-08-06-subagent-list-identity-projection.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) @@ -248,11 +248,11 @@ interface ContinuableCreateSpec { 描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果契约,而非持久化身份)。 -本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前追加描述符;`header.seedLength` 仍是 fork 谱系边界,因此描述符查找会读取子 agent 自身的后缀。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 +本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前追加描述符;`header.seedLength` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 ## 持久化枚举:`listChildren()` 与 `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` 从一次 `ctx.sessionQuery.traceSession()` 观测中枚举 parent 直接且由会话支撑的 subagent,而不会加载或恢复任何 Agent。会话谱系的范围比 subagent 身份更广——普通 fork 也会共享 `parentSession`——因此,child 自身后缀中恰好一个受支持的 `subagent/descriptor` 事件(位于 `seedLength` 之后,避免 fork seed 泄漏祖先描述符)是唯一的 subagent 判别信息。`SessionHeader.origin: 'subagent'` 只是在发布前写入的粗粒度产品导航分类器;它可以隐藏重复的侧边栏行,却不能证明描述符有效、child 可恢复或操作已获授权。结果是一个按追踪结果中 `createdAt`、再按 id 排列候选顺序的 `SubagentListEntry[]`:有效描述符生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。逐 child 检查失败生成 `diagnostic` 条目(`corrupt`、`unsupported` 或 `unavailable`),因此一个损坏的 sibling 不会隐藏健康 child;缺少描述符则不生成条目。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。构建初始追踪时的失败会让整个调用失败——只有得到可信候选集后才开始逐 child 隔离。服务将 `sessionQuery` 保持为按 id 继续执行时的可选依赖:缺少该服务时,`listChildren()` 抛出 `SubagentError`,并携带错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`;列表工具则在插件加载时要求 `ctx.subagents` 与 `ctx.sessionQuery`。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。 +`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值:存活 child 由注册表水位缓存同步供值(零日志读取),冷 child 在一次 `persistence.inspect()` 读取上折叠一次(有界并发,每次列表重新计算——无缓存)。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,并且在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 ## 终态结果:`SubagentResult` @@ -344,7 +344,7 @@ interface SubagentRun { } ``` -本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`,并在子 agent 的初始轮次内、首次请求前追加已解析的描述符。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`;由于没有本地 child Session,它不会出现在基于追踪的枚举结果中。 +本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`,并在子 agent 的初始轮次内、首次请求前追加已解析的描述符。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`;由于没有本地 child Session,它不会出现在持久化枚举结果中。 <a id="the-provider-seam-subagentprovider"></a> diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 6edcee5cd8..72984de195 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -80,6 +80,12 @@ maxTokens: 8192 compactionRetries: 1 +# Projection registry: subagent catalog identity (mode/label) folds through +# its registered units; the catalog surfaces (`list_agents`, subagent listing) +# fail loud without the capability. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + # Expose fresh-child `spawn` and completed-prefix `fork` through separate tool # names so multi-child scenarios exercise both transports. These leaves follow # the app because it provides `ctx.agents` and `ctx.tools`. diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 937c976c67..6c05dfccff 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -71,6 +71,11 @@ maxTokens: 8192 compactionRetries: 1 +# Projection registry: durable subagent identity (mode/label) folds through +# its registered units; subagent catalog reads fail loud without the capability. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + # Expose fresh-child `spawn` and completed-prefix `fork` through independent # in-process backends. - id: subagent diff --git a/examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml b/examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml new file mode 100644 index 0000000000..2e89c3753d --- /dev/null +++ b/examples/headless-agent/subagent-diagnostic.cordis.snapshot.yml @@ -0,0 +1,44 @@ +# Keyless real-Loader composition for the descriptor-less cold-child +# diagnostic snapshot. The seeded parent owns one session-backed child whose +# log carries `origin: 'subagent'` but no descriptor event, so the projection +# fold produces no identity and `list_agents` must surface the child as a +# `[diagnostic: corrupt]` row instead of silently dropping it. + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: none + +# file/override both default to their DSH_SNAPSHOT_* env vars. +- id: replay + name: '@deepseek-ai/dsh-llm-replay' + +# This scenario probes the subagent catalog only, so the bash/filesystem +# stacks are absent; the bundle must opt out of the tools that would wait +# forever for executors this tree never mounts. +- id: agent + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + agents: [] + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + goals: false + +# Projection registry: the cold child's identity fold runs through it; the +# catalog read fails loud when the capability is absent. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + +# Await the persisted resume before the headless driver inspects root agents. +- id: resumed-agent + name: './tests/fixtures/subagent-diagnostic-agent.ts' diff --git a/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts b/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts new file mode 100644 index 0000000000..f77afe7e0a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts @@ -0,0 +1,26 @@ +/** + * Loader fixture that resumes the seeded diagnostic-scenario parent before + * CLI dispatch, so `list_agents` runs against its pre-seeded cold child. + * @module subagent-diagnostic-agent + */ + +import type { Context } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Fixture plugin name. */ +export const name = 'subagent-diagnostic-agent' +/** Services that must exist before the fixture resumes its agent. */ +export const inject = ['agents', 'agentLoop', 'sessionPersistence'] + +/** + * Resume the seeded session and bind its exact 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<void> { + const handle = await ctx.agents.resume({ + resumeSessionId: 'subagent-diagnostic-parent' as SessionId, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) + ctx.effect(() => () => handle.dispose(), 'subagent-diagnostic-agent.handle') +} diff --git a/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl new file mode 100644 index 0000000000..edf331d8e3 --- /dev/null +++ b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl @@ -0,0 +1,31 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a background task."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"turn/end","seq":2,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/end-seed","seq":3,"time":0,"data":{}} +{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":5,"time":0,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":6,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Start a background task.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"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":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"list-once","name":"list_agents","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","seq":18,"time":0,"data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} +{"type":"tool/result","seq":19,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":21,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The stored subagent is unreadable. PARENT_DONE"}}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":27,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":29,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json new file mode 100644 index 0000000000..2b9facf71f --- /dev/null +++ b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/replay.override.json @@ -0,0 +1 @@ +[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "list-once", "name": "list_agents", "argumentsDelta": "{}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "list-once", "name": "list_agents", "arguments": "{}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The stored subagent is unreadable. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The stored subagent is unreadable. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}] diff --git a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts new file mode 100644 index 0000000000..dc978b37f6 --- /dev/null +++ b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts @@ -0,0 +1,119 @@ +/** + * Assembled-app regression: a persisted `origin: 'subagent'` child whose log + * carries no descriptor event is surfaced by `list_agents` as a + * `[diagnostic: corrupt]` row instead of being silently dropped. + */ + +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { 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 { describe, expect, it } from 'vitest' + +const fixtureDir = fileURLToPath(new URL('./subagent-diagnostic-snapshots/descriptorless-child', import.meta.url)) +const replayOverride = join(fixtureDir, 'replay.override.json') +const parentExpected = join(fixtureDir, 'parent.expected.jsonl') +const configPath = fileURLToPath(new URL('../subagent-diagnostic.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 parentId = SessionId('subagent-diagnostic-parent') +const childId = SessionId('subagent-diagnostic-child') +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' +const task = 'Call list_agents once and report what it shows.' + +/** + * Seed a completed parent turn plus one cold child that durably classifies + * as a subagent (`origin`) but never appended its descriptor event — the + * publication-window death the diagnostic row exists for. + */ +async function seedDescriptorlessChild(root: string, cwd: string): Promise<void> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const parentMeta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: parentId, + createdAt: 1, + cwd, + delegationDepth: 0, + } + const parentEvents: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, + { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background task.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, + { type: 'turn/end', seq: 2, time: 12, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const childMeta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: childId, + createdAt: 2, + cwd, + parentSession: parentId, + origin: 'subagent', + delegationDepth: 1, + } + const childEvents: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 20, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 21, data: { turn: 1, reason: { kind: 'interrupted' } } }, + ] + try { + await ctx.sessionPersistence.create(parentMeta) + await ctx.sessionPersistence.append(parentId, parentEvents) + await ctx.sessionPersistence.create(childMeta) + await ctx.sessionPersistence.append(childId, childEvents) + } finally { + await ctx.fiber.dispose() + } +} + +describe('descriptor-less cold child diagnostic snapshot', () => { + it('surfaces the unreadable child as a corrupt diagnostic through the assembled headless app', async () => { + let cwd = '' + const result = await runLoaderSmoke({ + label: 'subagent diagnostic headless stream-json snapshot', + tempDirPrefix: 'dsh-subagent-diag-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', task], + tsconfigPath, + env: { + DSH_SNAPSHOT_FILE: replayOverride, + DSH_SNAPSHOT_OVERRIDE: replayOverride, + }, + prepare: async (runCwd) => { + cwd = runCwd + await seedDescriptorlessChild(join(runCwd, '.sessions'), runCwd) + }, + inspect: async (runCwd) => { + const sessionsDir = join(runCwd, '.sessions') + const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl')) + const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8'))) + const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"')) + if (parent === undefined) throw new Error('missing persisted parent log') + + // THE model-visible fact: the descriptor-less child is reported, not + // silently dropped, and its reason is the corrupt classification. + expect(parent).toContain(`${childId} [diagnostic: corrupt]`) + + const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } + const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context)) + if (refreshing) { + await writeFile(parentExpected, normalizedParent) + } + expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8')) + }, + }) + + expect(result.stderr).toBe('') + const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>) + expect(records.at(-1)).toMatchObject({ + type: 'result', + sessionId: parentId, + output: 'The stored subagent is unreadable. PARENT_DONE', + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/package.json b/examples/package.json index ba947fccdb..5379595f23 100644 --- a/examples/package.json +++ b/examples/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 8b01e9005a..78a67ef642 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -190,6 +190,7 @@ describe('subagent ownership fence', () => { const meta = header('session-child', 1000, { parentSession: sid('session-parent'), seedLength: 0, + origin: 'subagent', }) const events = [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -245,6 +246,47 @@ describe('subagent ownership fence', () => { expect(inspect).toHaveBeenCalledTimes(3) }) + it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-legacy-child') + const meta = header('session-legacy-child', 1000, { + parentSession: sid('session-parent'), + seedLength: 0, + }) + const events = [ + { + type: 'subagent/descriptor', + seq: 0, + time: 1, + data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, + }, + ] as SessionEvent[] + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + locate: () => undefined, + } as never) + // Pre-#1569 stores classify a child only through the descriptor event and + // carry no header `origin`; the pre-release decision stops recognizing + // them, so the ownership fence lets generic resume reach the registry + // instead of answering `agent-busy`. + const resume = vi.spyOn(ctx.agents, 'resume') + .mockRejectedValue(new Error('registry unavailable in this bench')) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const prompt = await api.sessions.prompt(request({ + sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'follow up' }], + })) + expect(resume).toHaveBeenCalledTimes(1) + expect(prompt.result.ok).toBe(false) + if (!prompt.result.ok) expect(prompt.result.error.code).toBe('internal') + }) + it('rejects origin-marked and runtime-owned live children from generic controls', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 57634ecfac..03e0bb3367 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -304,8 +304,8 @@ export class SubagentService extends Service { * @param signal - caller-owned cancellation forwarded to persistence reads * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry is not mounted - * or the caller cancels the listing. + * @throws {@link SubagentError} when the projection registry or the session + * store is not mounted, or the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> { return listSubagentChildren(this.ctx, parentSessionId, signal) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 6b055be4a6..dbea888816 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -22,7 +22,11 @@ import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-project import { SubagentError } from './error.ts' import type { SubagentIdentityProjection } from './projection-types.ts' -/** Concurrent cold inspections per listing; a constant because it bounds one read-only scan, not deployment behavior. */ +/** + * Concurrent cold inspections per listing; a constant because it bounds one + * read-only scan of local media, not deployment behavior. Should a networked + * persistence backend appear, promote it to a validated `Config` field. + */ const COLD_READ_CONCURRENCY = 4 /** @@ -90,8 +94,8 @@ export type SubagentListEntry = * @param parentSessionId - parent session whose direct children are listed. * @param signal - caller-owned cancellation observed around every persistence read. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry is not mounted - * or the caller cancels the listing. + * @throws {@link SubagentError} when the projection registry or the session + * store is not mounted, or the caller cancels the listing. */ export async function listChildren( ctx: Context, @@ -99,7 +103,6 @@ export async function listChildren( signal?: AbortSignal, ): Promise<SubagentListEntry[]> { const projections = ctx.get('sessionProjections') - const sessions = ctx.get('sessions') // Checked before any read, even with zero candidates: mode/label are the // row's strong contract, so a missing fold capability is a deterministic // deployment configuration error, never an empty success. @@ -109,10 +112,14 @@ export async function listChildren( 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', ) } + // Strict global read, never the `ctx.sessions` property proxy: the proxy is + // caller-scope bound, so a consumer plugin without its own `sessions` + // injection (the model-facing tool, the API proxy) would throw on access. + const sessions = ctx.get('sessions') if (sessions === undefined) { throw new SubagentError( - 'listing subagents requires the sessions registry (load @deepseek-ai/dsh-session)', - 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', + 'listing subagents requires the session store (load @deepseek-ai/dsh-session)', + 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE', ) } assertListingNotCancelled(signal) @@ -146,7 +153,7 @@ export async function listChildren( .filter(record => record.header.parentSession === parentSessionId && record.header.origin === 'subagent') .sort((a, b) => a.header.createdAt - b.header.createdAt - || (a.header.id < b.header.id ? -1 : a.header.id > b.header.id ? 1 : 0)) + || a.header.id.localeCompare(b.header.id)) const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length }) const coldReads: { index: number; id: SessionId }[] = [] @@ -196,7 +203,7 @@ async function inspectColdIdentity( childId: SessionId, hasChildren: boolean, signal: AbortSignal | undefined, -): Promise<SubagentListEntry | undefined> { +): Promise<SubagentListEntry> { assertListingNotCancelled(signal) let events: readonly SessionEvent[] try { diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 41b0d093ad..5fa5d70ab8 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -139,9 +139,11 @@ ProjectionDefinition<'subagent', IdentityState> = { const identity = descriptorIdentity(event) return identity === undefined ? {} : { identity } }, - // A no-value log serves `undefined` (the schema's optional side); the map - // entry stays non-optional because every consumer reads through `Partial` - // snapshot values, where absence is already the type. + // The assertion deliberately widens: a log without a descriptor serves + // `undefined` at runtime, which the schema's `.optional()` accepts, and + // every registry read face already returns `Partial` snapshot values where + // absence is the type. The map entry stays non-optional so a child row's + // served identity remains a strong contract for consumers. view: state => state.identity as SubagentIdentityProjection, stateVersion: 1, } diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 18aab0a2ca..1d05f0467d 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -136,6 +136,15 @@ describe('SubagentService.listChildren', () => { ) }) + it('fails loud when the session store is not mounted', async () => { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentService) + await expect(ctx.subagents.listChildren(SessionId('no-store-parent'))).rejects.toThrow( + expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' }) as Error, + ) + }) + it('lists a persisted continuable child as inactive with its durable label', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'summarize the doc') @@ -207,31 +216,57 @@ describe('SubagentService.listChildren', () => { it('orders children by createdAt then id without listing ordinary forks', async () => { const { ctx, parent } = await setup([]) - // Authored headers pin the ordering key deterministically: same createdAt - // ties break on id, different createdAt orders ascending. - const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', { - parentSession: parent.id, - createdAt: 9, - origin: 'subagent', - }, childEvents(descriptorPayload('late child'))) - const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', { - parentSession: parent.id, - createdAt: 5, - origin: 'subagent', - }, childEvents(descriptorPayload('tie b'))) - const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', { - parentSession: parent.id, - createdAt: 5, - origin: 'subagent', - }, childEvents(descriptorPayload('tie a'))) + /** Publish one live child with a pinned header ordering key. */ + const liveChild = (parentId: SessionId, id: string, createdAt: number, label: string): SessionId => { + const session = ctx.sessions.create(SessionId(id), { + meta: { parentSession: parentId, origin: 'subagent', createdAt }, + }) + session.append('turn/start', { turn: 1 }) + session.append('subagent/descriptor', descriptorPayload(label)) + return session.header.id + } + // Live creation order is deliberately shuffled against the expected + // result: same-createdAt ties break on id, different createdAt orders + // ascending. + const late = liveChild(parent.id, '00000000-0000-4000-8000-000000000009', 9, 'late child') + const tieB = liveChild(parent.id, '00000000-0000-4000-8000-000000000002', 5, 'tie b') + const tieA = liveChild(parent.id, '00000000-0000-4000-8000-000000000001', 5, 'tie a') // An ordinary session fork shares parentSession but has no subagent origin. const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork')) await ctx.sessions.flush(fork) - const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') const entries = await ctx.subagents.listChildren(parent.id) expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late]) expect(entries.every(entry => entry.kind === 'child')).toBe(true) - expect(inspect).not.toHaveBeenCalledWith(fork.id, expect.anything()) + }) + + it('omits a live child that has not appended its descriptor yet', async () => { + const { ctx, parent } = await setup([]) + const pending = ctx.sessions.create(SessionId('creation-window-child'), { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + pending.append('turn/start', { turn: 1 }) + // The creation window: the establishing provider has not appended the + // descriptor yet, so the row is omitted rather than diagnosed. + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([]) + }) + + it('lists a one-shot child with its durable creation label', async () => { + const { ctx, parent } = await setup([]) + const labeled = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab02', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents({ + version: SUBAGENT_DESCRIPTOR_VERSION, + mode: 'one-shot', + provider: 'spawn', + label: 'labeled one-shot', + })) + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([ + { + kind: 'child', id: labeled, mode: 'one-shot', label: 'labeled one-shot', + activity: 'inactive', hasChildren: false, + }, + ]) }) it('reports a live child as running while keeping settled siblings complete', async () => { @@ -462,6 +497,35 @@ describe('SubagentService.listChildren', () => { expect(inspected).not.toContain(grandchildId) }) + it('inspects each cold child exactly once and a live child never', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const coldStarted = await startChild(ctx, parent, 'cold started child') + const coldAuthored = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab01', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('cold authored child'))) + const liveId = SessionId('live-mixed-child') + const live = ctx.sessions.create(liveId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + live.append('turn/start', { turn: 1 }) + live.append('subagent/descriptor', descriptorPayload('live mixed child')) + + const inspected: SessionId[] = [] + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = (sessionId, signal) => { + inspected.push(sessionId) + return original(sessionId, signal) + } + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toHaveLength(3) + // The cost model: one inspection per cold child, none for a live child, + // whose identity is served from the registry's watermark cache. + expect(inspected.filter(id => id === coldStarted)).toHaveLength(1) + expect(inspected.filter(id => id === coldAuthored)).toHaveLength(1) + expect(inspected).not.toContain(liveId) + }) + it('does not count an ordinary grandchild without subagent origin', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'direct child') diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 3a650db8fa..8c7841ef63 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 217d388fb4..e734ae8222 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -8,6 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' @@ -28,6 +29,7 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) { roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 302e053abe..db674b0599 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -8,6 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -27,6 +28,7 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) { roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1703d3ef7..fb1f5ae494 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,6 +418,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:* version: link:../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../packages/session-projection/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:* version: link:../packages/session-query/session-query @@ -5587,6 +5590,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent From 96e7c0496af842e9e9816d0d91b0f8f823893081 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:03:55 +0800 Subject: [PATCH 159/190] fix: regenerate cordis catalog and allowlist the diagnostic fixture The subagent API surface change staled the committed catalog artifacts; the snapshot fixture agent is referenced only from its cordis.snapshot.yml, so knip learns it as an entry like its siblings. --- docs/cordis-catalog/services.md | 34 +++++++++++-------- knip.json | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 44a44a139e..d4c944ef9d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2099,22 +2099,28 @@ registerContinuableSetup(contribution: ContinuableSetupContribution): () => void async drainContinuableDescendants(parents: readonly Agent[]): Promise<void> /** - * Enumerate the parent's direct session-backed subagents from the - * live-preferred session corpus without loading or resuming an Agent. Session - * query supplies lineage, candidate order, event reads, and live state; this - * service interprets descriptor mode, activity, and per-child diagnostics - * without consulting Agent registrations, Activations, or providers. + * Enumerate the parent's direct session-backed subagents without loading or + * resuming an Agent and without any query seam: the listing merges the live + * session store with optional session persistence (live-preferred) and + * serves each child's durable mode/label from the registered `subagent` + * projection unit — the registry's watermark snapshot for a live child, one + * persistence inspection folded through the registry for a cold one. The + * projection fold is the single classification authority; per-child + * diagnostics relay a fold that served no identity or a failed inspection, + * never a list-time descriptor parse. Absent persistence, enumeration is + * live-only (a cold child cannot be resumed then either, so its absence is + * capability absence, not an error). This service consults no Agent + * registrations, Activations, or providers. * - * The trace and exact descriptor read receive `signal`; the full event-list - * read has no signal parameter, so the scan rechecks cancellation around - * every await and between candidates. Query rejections that settle after an - * abort become a stable `SubagentError` with code `CANCELLED`. + * Every persistence read receives `signal`, and the listing rechecks + * cancellation around each of those awaits. Read rejections that settle + * after an abort become a stable `SubagentError` with code `CANCELLED`. * @param parentSessionId - parent session whose direct children are listed. - * @param signal - caller-owned cancellation forwarded where supported and - * observed around every query await. - * @returns children and per-child diagnostics in stable trace order. - * @throws {@link SubagentError} when session query is unavailable or the - * caller cancels the scan. + * @param signal - caller-owned cancellation forwarded to persistence reads + * and observed around every read await. + * @returns children and per-child diagnostics ordered by `createdAt`, then id. + * @throws {@link SubagentError} when the projection registry or the session + * store is not mounted, or the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> diff --git a/knip.json b/knip.json index 9b72396f1a..6dc4b56dcd 100644 --- a/knip.json +++ b/knip.json @@ -36,6 +36,7 @@ "entry": [ "headless-agent/tests/fixtures/cli-mock-llm.ts", "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", + "headless-agent/tests/fixtures/subagent-diagnostic-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", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9f520a012d..7c21ac00d1 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -938,7 +938,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>', - jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents from the\n * live-preferred session corpus without loading or resuming an Agent. Session\n * query supplies lineage, candidate order, event reads, and live state; this\n * service interprets descriptor mode, activity, and per-child diagnostics\n * without consulting Agent registrations, Activations, or providers.\n *\n * The trace and exact descriptor read receive `signal`; the full event-list\n * read has no signal parameter, so the scan rechecks cancellation around\n * every await and between candidates. Query rejections that settle after an\n * abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded where supported and\n * observed around every query await.\n * @returns children and per-child diagnostics in stable trace order.\n * @throws {@link SubagentError} when session query is unavailable or the\n * caller cancels the scan.\n */', + jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit — the registry\'s watermark snapshot for a live child, one\n * persistence inspection folded through the registry for a cold one. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */', }, { signature: 'registerProvider(provider: SubagentProvider): () => void', From ac1dffb8094d1d8866cdbe00ffb4fe9cbc9f098d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:06:44 +0800 Subject: [PATCH 160/190] test(session-query): pin the persisted-corruption wrapping branch The retired subagent list path was the only caller exercising inspectPersisted's corruption arm; cover it directly. --- .../session-query/tests/session-query.spec.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index ebcad51bd7..acc993d2b3 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import SessionQueryService, { SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, type SessionEventSurface, @@ -1114,6 +1114,24 @@ describe('session-query exact reads', () => { await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) + it('wraps persisted corruption as SESSION_QUERY_CORRUPT_SESSION with its cause preserved', async () => { + const durable = header('durable-corrupt') + TestPersistence.reset([{ meta: durable, events: eventLog() }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const corruption = new SessionPersistenceCorruptionError( + 'stored prefix failed validation', + { cause: new Error('torn final record') }, + ) + TestPersistence.inspectFailure = corruption + + await expect(ctx.sessionQuery.readSession(durable.id)).rejects.toMatchObject({ + code: 'SESSION_QUERY_CORRUPT_SESSION', + message: `stored session "${durable.id}" is corrupt: stored prefix failed validation`, + cause: corruption, + }) + }) + it('reports absent sessions, persisted load failures, and persisted header conflicts', async () => { const durable = header('durable') TestPersistence.reset([{ meta: durable, events: eventLog() }]) From efd78f44f4bb2090ad6b60b0df814c39d4ad9c81 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:17:23 +0800 Subject: [PATCH 161/190] fix: refresh graph docs and retire a dead spec reference Mounting session-projection in the example compositions staled the generated composition and module graphs; the 2026-07-22 note now describes the retired optional-session-query spec without a live path. --- ...07-22-durable-subagent-catalog-and-list-agents.i18n.yaml | 4 ++-- .../2026-07-22-durable-subagent-catalog-and-list-agents.md | 2 +- ...026-07-22-durable-subagent-catalog-and-list-agents.zh.md | 2 +- docs/module-graph.md | 6 ++---- examples/acp-agent/composition.md | 3 +++ examples/headless-agent/composition.md | 3 +++ 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index 74932324dd..2b316aba2e 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: 1de93cc1374e8e86bace6af94b51efe94b38f89a -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: fe5422c497b87bb39d43ac97cb5d1a9bed9fcbfb +2026-07-22-durable-subagent-catalog-and-list-agents.md: 12be9152edc1972337f96c098c8f7d93b723530c +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 7dee4ca59ff6dd1ace32e6779dec240038d2c483 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 1de93cc137..12be9152ed 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -93,7 +93,7 @@ The first version has no child deletion operation. If later product behavior del ## Testing - `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics. -- `packages/subagent/subagent/tests/list-children.spec.ts` pins a query-only composition with sessions, `subagents`, and `sessionQuery` but no `agents`, then drives the full real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: one-shot and continuable children from one real trace; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `inactive`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; forwarded trace/exact-read cancellation with stable `CANCELLED` normalization; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract. `packages/subagent/subagent/tests/optional-session-query.spec.ts` rejects eager evaluation of the optional runtime while importing the ordinary subagent surface. +- `packages/subagent/subagent/tests/list-children.spec.ts` pins a query-only composition with sessions, `subagents`, and `sessionQuery` but no `agents`, then drives the full real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: one-shot and continuable children from one real trace; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `inactive`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; forwarded trace/exact-read cancellation with stable `CANCELLED` normalization; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface. - `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, load-time `sessionQuery` injection, and HMR disposal. - The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, session query, and JSONL persistence, rendering `<id> [complete] — <label>`. - The keyless ACP snapshot scenario `subagent-published-run-failure` publishes a real one-shot child, injects independent run-result and handle-disposal failures, and preserves both diagnostics in the parent tool result. diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index fe5422c497..7dee4ca59f 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -93,7 +93,7 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导 ## 测试 - `packages/subagent/subagent/tests/service.spec.ts` 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。`packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id,并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递,并保留相互独立的结果与 dispose diagnostic。 -- `packages/subagent/subagent/tests/list-children.spec.ts` 先固定一个只有会话、`subagents` 和 `sessionQuery` 而没有 `agents` 的纯查询组合,再以无密钥方式驱动完整真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、subagent 服务,以及一个具体的会话查询服务):来自同一真实追踪的一次性与可继续 child;只存在于持久化存储中(重启形态)的 parent 目标;带有人工构造并列项的按 `createdAt` 再按 id 排序;排除普通 fork 和 fork seed 中祖先描述符且不产生 diagnostic;存活 `running` 与持久化 `inactive` 的对比;重复描述符、载荷格式错误、无效 surface、header 不匹配和读取目标已变化的损坏 diagnostic 均不隐藏健康的 sibling;不受支持版本与逐 child unavailable diagnostic;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;排除孙代会话;追踪阶段失败导致整次调用失败而候选阶段失败只隔离到单个 child;配置/窗口错误和无法识别的失败作为操作失败向上传播;转发 trace/精确读取取消并稳定归一化为 `CANCELLED`;以及 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 缺服务契约。`packages/subagent/subagent/tests/optional-session-query.spec.ts` 会在导入普通 subagent surface 时拒绝对可选运行时的 eager 求值。 +- `packages/subagent/subagent/tests/list-children.spec.ts` 先固定一个只有会话、`subagents` 和 `sessionQuery` 而没有 `agents` 的纯查询组合,再以无密钥方式驱动完整真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、subagent 服务,以及一个具体的会话查询服务):来自同一真实追踪的一次性与可继续 child;只存在于持久化存储中(重启形态)的 parent 目标;带有人工构造并列项的按 `createdAt` 再按 id 排序;排除普通 fork 和 fork seed 中祖先描述符且不产生 diagnostic;存活 `running` 与持久化 `inactive` 的对比;重复描述符、载荷格式错误、无效 surface、header 不匹配和读取目标已变化的损坏 diagnostic 均不隐藏健康的 sibling;不受支持版本与逐 child unavailable diagnostic;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;排除孙代会话;追踪阶段失败导致整次调用失败而候选阶段失败只隔离到单个 child;配置/窗口错误和无法识别的失败作为操作失败向上传播;转发 trace/精确读取取消并稳定归一化为 `CANCELLED`;以及 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 缺服务契约。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。 - `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、child/diagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、工具取消信号的转发、无调用 agent 时的拒绝、加载时的 `sessionQuery` 注入,以及 HMR dispose。 - 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、会话查询和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`。 - 无密钥 ACP 快照场景 `subagent-published-run-failure` 会发布一个真实的一次性 child,注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 390f4bcdec..0085b96f34 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -757,7 +757,6 @@ flowchart TD pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence pkg_subagent --> pkg_session_projection - pkg_subagent --> pkg_session_query pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools pkg_tool_web --> pkg_invariants @@ -955,7 +954,6 @@ flowchart TD pkg_tool_subagent_control --> pkg_invariants pkg_tool_subagent_control --> pkg_llm pkg_tool_subagent_control --> pkg_session - pkg_tool_subagent_control --> pkg_session_query pkg_tool_subagent_control --> pkg_subagent pkg_tool_subagent_control --> pkg_tools pkg_tool_subagent_report --> pkg_invariants @@ -1245,7 +1243,7 @@ flowchart TD | [`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) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`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) | +| [`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), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1277,7 +1275,7 @@ flowchart TD | [`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), [`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) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index e4e21c785a..5e6a21e1ec 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -33,6 +33,8 @@ flowchart LR cfg --> plugin_acp_token_meter plugin_acp_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"] cfg --> plugin_acp_compact_basic + plugin_acp_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"] + cfg --> plugin_acp_session_projection plugin_acp_subagent["subagent<br/>@deepseek-ai/dsh-subagent"] cfg --> plugin_acp_subagent plugin_acp_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"] @@ -82,6 +84,7 @@ flowchart LR | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `session-projection` | `@deepseek-ai/dsh-session-projection` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 7fd2c936a2..6ef139d3d9 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -31,6 +31,8 @@ flowchart LR cfg --> plugin_headless_token_meter plugin_headless_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"] cfg --> plugin_headless_compact_basic + plugin_headless_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"] + cfg --> plugin_headless_session_projection plugin_headless_subagent["subagent<br/>@deepseek-ai/dsh-subagent"] cfg --> plugin_headless_subagent plugin_headless_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"] @@ -71,6 +73,7 @@ flowchart LR | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `session-projection` | `@deepseek-ai/dsh-session-projection` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | From 6ff4fc0ed0547a951348ac422ae5e0a339d90d90 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:41:50 +0800 Subject: [PATCH 162/190] fix: address review round two - listChildren contains per-child projection faults on both ladder rungs (any registered unit's fold/schema rejection maps to that child's corrupt diagnostic) and pins the whole-enumeration listing-failure rethrow - the base bundle mounts session-projection (web-app's own insert retired to avoid the double mount); stale composition comment updated - the shared projections-unavailable wire face is pinned across list/history/prompt; retired session-query arms removed from the catalog paths - the design note records the unknown-parent semantics shift and the fold-fault isolation rule --- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 6 +- ...06-subagent-list-identity-projection.zh.md | 6 +- apps/cli/composition.md | 3 + examples/acp-agent/cordis.yml | 4 +- packages/bundle/base/cordis.patch.yml | 6 ++ packages/bundle/base/package.json | 1 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 3 - packages/bundle/web-app/package.json | 1 - packages/host/apiproxy/src/api-proxy.ts | 17 +--- .../tests/api-proxy-subagents.spec.ts | 27 +++++++ .../subagent/subagent/src/list-children.ts | 35 ++++++-- .../subagent/tests/list-children.spec.ts | 80 +++++++++++++++++++ pnpm-lock.yaml | 6 +- 17 files changed, 166 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 4620ec99c9..4d500d7519 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 6b6ee863bf385e27f4c431f7a1039ea75110565e -2026-08-06-subagent-list-identity-projection.zh.md: 42a578147026ae8d09669d13ada468a4491dcb37 +2026-08-06-subagent-list-identity-projection.md: d23e068b6dbbc08d62bec18b0b00e9651a502a86 +2026-08-06-subagent-list-identity-projection.zh.md: 45bdd41ba07e48b70e8030fb6e61ada944f80645 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index 6b6ee863bf..d23e068b6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -115,6 +115,7 @@ For each enumerated child, the ladder's result maps to a row through four states - `unsupported` is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced. - Descriptor-less settled debris moves from the old implementation's omit into the `corrupt` diagnostic — damaged, dead child sessions in the corpus are visible rather than silently vanishing, which is exactly the original motivation for keeping diagnostics. +- Any registered unit whose fold/schema throws on this child's log is likewise contained as that child's diagnostic row, reason `corrupt` — a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics; live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold throw means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit. Known boundary deviations (deliberately accepted, recorded with this note): @@ -122,6 +123,7 @@ Known boundary deviations (deliberately accepted, recorded with this note): - Multiple descriptors in the own suffix: the old implementation judged corrupt; last-wins now takes the final one (the provider contract guarantees exactly one anyway). - A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. - A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). +- An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral change is the apiproxy route segment: the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this. @@ -158,7 +160,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Verification -`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots. +`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots. ## Consequences @@ -166,7 +168,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir - The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`. - Identity interpretation exists only in the single unit registered with the registry: the list's two-tier ladder and GUI history's cold read use the same two reads (snapshot/restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. - Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. -- The diagnostic semantics leaves four boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, and damaged-source read failures shifting from `corrupt` to `unavailable`); the full semantics is in the known-boundary-deviations list; all are display or classification deviations on debris-grade data, and resume authorization is unaffected. +- The diagnostic and enumeration semantics leaves five boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, and an unknown parent yielding an empty list instead of not-found); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data with resume authorization unaffected, and the unknown-parent one is a silent query-semantics change, explicitly accepted. - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. ## Related diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index 42a5781470..45bdd41ba0 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -115,6 +115,7 @@ export type SubagentListEntry = - `unsupported` 不再被产出:类型与 wire 枚举按"数据结构保持现状"留存该成员,本记录留档其为不再产出。 - descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。 +- 任一注册 unit 的 fold/schema 在该 child 日志上抛错,同样收纳为该 child 的 diagnostic 行,reason `corrupt`——确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义;live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是"尚无数据",fold 抛错是"数据坏了"——running 的中毒 child 也出 `corrupt` 行而非 omit。 已知边界偏差(有意接受,随本记录留档): @@ -122,6 +123,7 @@ export type SubagentListEntry = - own suffix 出现多个描述符,旧实现判 corrupt,现 last-wins 取末者(provider 契约本就保证恰一)。 - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 +- 未知 parent,旧实现经 session-query 抛 not-found('parent session … was not found');现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上唯一动的是 apiproxy 路由段:`hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 @@ -158,7 +160,7 @@ export type SubagentListEntry = ## 验证 -`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 +`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 ## 后果 @@ -166,7 +168,7 @@ export type SubagentListEntry = - subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。 - 身份解释只存在于 registry 注册的一份 unit:列表两级阶梯与 GUI history 冷读走同两处读法(snapshot/restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 - per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 -- 诊断语义留下四处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`),完整语义见已知边界偏差清单;均为残骸级数据的展示或分类偏差,恢复鉴权不受影响。 +- 诊断与枚举语义留下五处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,恢复鉴权不受影响,未知 parent 一处是查询语义的静默变化,显式接受。 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 ## 相关 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 462b528a30..618f25c667 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,6 +40,8 @@ flowchart LR cfg --> plugin_dsh_base_session_persistence_jsonl plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"] cfg --> plugin_dsh_base_session_query_sqlite + plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"] + cfg --> plugin_dsh_base_session_projection plugin_dsh_base_telemetry_otel["telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"] cfg --> plugin_dsh_base_telemetry_otel plugin_dsh_base_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"] @@ -166,6 +168,7 @@ flowchart LR | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | +| `session-projection` | `@deepseek-ai/dsh-session-projection` | | `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 72984de195..2b440e77ac 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -104,8 +104,8 @@ # Continuable background children are selected per delegation tool. The # separately loaded control package registers the global `send_message`; its -# list plugin registers `list_agents` and requires the app's session query. -# `report` is installed only in continuable child scopes. +# list plugin registers `list_agents`, served through the sessionProjections +# registry mounted above. `report` is installed only in continuable child scopes. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c09c7da39c..4c006198fe 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -100,6 +100,12 @@ path: ':memory:' openAt: first-search + # Shared projection registry: subagent catalog identity (mode/label) folds + # through its registered units, so the `list_agents` surface below fails + # loud without it; web layers reuse this same mount for list rows. + - id: session-projection + name: '@deepseek-ai/dsh-session-projection' + # Session telemetry, on for every dsh mode: mirrors every session-log # event (assistant/chunk projected to first-of-step) plus ops markers onto # OTLP/HTTP log records, streaming on the batch processor's cadence diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 95e169cabb..14399cd441 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index bdf871b6ac..7662182df0 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: a835497982e41c29c5dbc287c43b0515a61f77c9 -README.zh.md: 00d8636e848a7927792dfc8ad3a460015c9e7fef +README.md: dc35cb4b596b265b70cea81aa5d6784fc1eff65b +README.zh.md: 0ffc5cdaf1a98e5df11ef042c7d15a994515c170 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index a835497982..dc35cb4b59 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 00d8636e84..0ffc5cdaf1 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index a0f53b787f..624e9e37af 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -45,9 +45,6 @@ # `dshClient` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: - - id: session-projection - name: '@deepseek-ai/dsh-session-projection' - - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 1b812b9943..0a406a7bf8 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -63,7 +63,6 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 9a93b89d99..835ca39728 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -609,23 +609,12 @@ async function catalogChild( } return { entry } } catch (error: unknown) { - if (signal?.aborted - || (error instanceof SubagentError && error.code === 'CANCELLED') - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) { return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } } } if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { return { error: projectionsUnavailableError() } } - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return { - error: { - code: 'subagent-not-found', - message: `parent session "${parentSessionId}" was not found`, - details: { parentSessionId, childSessionId }, - }, - } - } return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } } } } @@ -1903,9 +1892,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined, }) } catch (error: unknown) { - if (signal?.aborted - || (error instanceof SubagentError && error.code === 'CANCELLED') - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) { return err(request, { code: 'cancelled', message: 'subagent catalog read was cancelled', diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index faa1c39e9f..df0a8d53a3 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -157,6 +157,33 @@ describe('subagent gateway', () => { expect(readSession).not.toHaveBeenCalled() }) + it('maps the missing projections capability to one wire face on list, history, and prompt', async () => { + const listError = () => new SubagentError( + 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', + ) + const expected = { + code: 'internal', + message: 'subagent listing is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + } + + const list = bench({ listError: listError() }) + expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result) + .toMatchObject({ ok: false, error: expected }) + + const history = bench({ listError: listError() }) + expect((await history.api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + }))).result).toMatchObject({ ok: false, error: expected }) + expect(history.readSession).not.toHaveBeenCalled() + + const prompt = bench({ listError: listError() }) + expect((await prompt.api.subagents.prompt(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], + }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected }) + expect(prompt.followup).not.toHaveBeenCalled() + }) + it('routes human content through the exact live parent with rpc attribution', async () => { const { api, parent, followup } = bench() const content = [{ type: 'text' as const, text: '继续' }] diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index dbea888816..8116d3dd88 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -74,10 +74,12 @@ export type SubagentListEntry = /** * Why the candidate has no `child` row: `corrupt` for a settled candidate * whose projection fold served no identity (a missing, malformed, or - * unrecognized-version descriptor — deliberately undistinguished); - * `unavailable` when the candidate's persistence inspection failed - * (retried on the next listing). `unsupported` is kept for consumers - * already routing on it but is no longer produced. + * unrecognized-version descriptor — deliberately undistinguished), and + * for any candidate whose log makes a registered unit's fold or schema + * throw (deterministic data damage, contained per child); `unavailable` + * when the candidate's persistence inspection failed (retried on the + * next listing). `unsupported` is kept for consumers already routing on + * it but is no longer produced. */ readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } @@ -166,7 +168,17 @@ export async function listChildren( // The registry's watermark cache serves the live value with zero log // reads; a live child without an identity yet is the creation window // before the establishing provider appends its descriptor. - const identity = projections.snapshot(candidate.live).values.subagent + let identity: SubagentIdentityProjection | undefined + try { + identity = projections.snapshot(candidate.live).values.subagent + } catch { + // The snapshot folds EVERY registered unit over this child's log, so + // any unit's fold or schema can reject damaged payloads. That is + // deterministic data damage in this one child; it degrades to one + // corrupt diagnostic instead of failing the whole listing. + rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' } + return + } if (identity === undefined) return rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId)) }) @@ -195,7 +207,8 @@ export async function listChildren( * projection registry (the same detached recipe the API proxy uses for * detached session projections). A failed inspection is one transient * `unavailable` row retried on the next listing; a settled log the fold - * cannot identify is final, so it reports `corrupt`. + * cannot identify — or that makes any registered unit throw — is final, so + * it reports `corrupt`. */ async function inspectColdIdentity( persistence: SessionPersistence, @@ -215,7 +228,15 @@ async function inspectColdIdentity( return { kind: 'diagnostic', id: childId, reason: 'unavailable' } } assertListingNotCancelled(signal) - const identity = projections.restore({}, events, 0).snapshot.values.subagent + let identity: SubagentIdentityProjection | undefined + try { + identity = projections.restore({}, events, 0).snapshot.values.subagent + } catch { + // The restore folds EVERY registered unit over this child's log, so any + // unit's fold or schema can reject damaged payloads — deterministic data + // damage in this one child, contained as its own corrupt diagnostic. + return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + } if (identity === undefined) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 1d05f0467d..8745496d16 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { z } from 'zod' import { Context } from 'cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -10,6 +11,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/ds import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError, @@ -100,6 +102,34 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) return { version, mode: 'continuable' as const, provider: 'spawn', label } } +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Test-only hostile probe proving per-child isolation of foreign unit failures. */ + subagentListHostileProbe: null + } +} + +/** + * A foreign registered unit that rejects one specific child's log at view + * time: `apply` never throws (the eager drive passes every committed event + * through it), while the poisoned state detonates only when a listing read + * folds or serves this child through the registry. + */ +const hostileProjectionDefinition: ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean }> = { + key: 'subagentListHostileProbe', + schema: z.null(), + init: () => ({}), + apply: (state, event) => + event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me' + ? { poisoned: true } + : state, + view: (state) => { + if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log') + return null + }, + stateVersion: 1, +} + describe('SubagentService.listChildren', () => { it('lists live children without persistence, query services, or the continuation runtime', async () => { const ctx = new Context() @@ -402,6 +432,56 @@ describe('SubagentService.listChildren', () => { ]) }) + it('contains a foreign unit failure during a cold fold to that child as corrupt', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + ctx.sessionProjections.register(hostileProjectionDefinition) + const healthy = await startChild(ctx, parent, 'healthy sibling') + const poisoned = await authorChild(ctx, '00000000-0000-4000-8000-00000000d00d', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('poison me'))) + // The subagent unit itself folds this child cleanly; the FOREIGN unit's + // view throws, and that damage stays contained to the one child. + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toContainEqual({ kind: 'diagnostic', id: poisoned, reason: 'corrupt' }) + expect(entries).toContainEqual({ + kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) + }) + + it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => { + const { ctx, parent } = await setup([]) + ctx.sessionProjections.register(hostileProjectionDefinition) + const poisonedId = SessionId('live-poisoned-child') + const poisoned = ctx.sessions.create(poisonedId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + poisoned.append('turn/start', { turn: 1 }) + poisoned.append('subagent/descriptor', descriptorPayload('poison me')) + const healthyId = SessionId('live-healthy-child') + const healthy = ctx.sessions.create(healthyId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + healthy.append('turn/start', { turn: 1 }) + healthy.append('subagent/descriptor', descriptorPayload('live healthy')) + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' }) + expect(entries).toContainEqual({ + kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable', + activity: 'running', hasChildren: false, + }) + }) + + it('fails the whole enumeration when the persisted listing itself fails', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + await startChild(ctx, parent, 'never listed') + ctx.sessionPersistence.list = () => Promise.reject(new Error('backend listing failed')) + // Without any abort in flight, the original backend failure propagates + // as the operation failure — no cancellation mapping, no diagnostic rows. + await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('backend listing failed') + }) + it('maps a failed cold inspection to one unavailable diagnostic and retries it next listing', async () => { const { ctx, parent } = await setup([textResponse('done')]) const healthy = await startChild(ctx, parent, 'healthy sibling') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb1f5ae494..6a2ba96a54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -946,6 +946,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-session-query-sqlite': specifier: workspace:^ version: link:../../session-query/session-query-sqlite @@ -1191,9 +1194,6 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session-projection/session-projection '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session-projection/session-projection-cache From a25d4331d712fd57f6fca7e041bb16e517aa50a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:16:28 +0800 Subject: [PATCH 163/190] feat(subagent): opportunistic projection-cache rung for cold listings Cold children consult the optional session-projection-cache checkpoint before paying a preparation recompute: the identity is immutable once appended, so a cached value is definitive regardless of its watermark. The cache stays a read-only accelerator (absent service or any rung-two fault falls through silently; verdicts stay with the authoritative refold), and the note plus core-data-structures pages describe the three-rung ladder. --- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 30 ++++--- ...06-subagent-list-identity-projection.zh.md | 28 +++--- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/package.json | 7 ++ packages/subagent/subagent/src/index.ts | 7 +- .../subagent/subagent/src/list-children.ts | 64 +++++++++---- .../subagent/tests/list-children.spec.ts | 89 ++++++++++++++++++- packages/subagent/subagent/tsconfig.json | 3 + pnpm-lock.yaml | 9 ++ 15 files changed, 199 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 4d500d7519..313dcf836e 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: d23e068b6dbbc08d62bec18b0b00e9651a502a86 -2026-08-06-subagent-list-identity-projection.zh.md: 45bdd41ba07e48b70e8030fb6e61ada944f80645 +2026-08-06-subagent-list-identity-projection.md: feea6a6634724c59a38bfbdc4c5dc138335899ec +2026-08-06-subagent-list-identity-projection.zh.md: ceb1f1ee2ea2856d073bf1718a651a162233d686 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index d23e068b6d..feea6a6634 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -14,14 +14,14 @@ The root cause is that the [durable-subagent-catalog decision](../feature/2026-0 ## Decision -mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a two-tier live/cold compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads), and a cold child pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache, no write-back. +mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served value is final; otherwise it pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache of its own, no write-back. There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was once this note's settled direction and was under construction for a time, then retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered. Key points: - **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual. -- **Value retrieval is a two-tier compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache, no write-back, no index. +- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when `subagent` is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index. - **The `subagent` projection unit is the sole authority over the fold rules**: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists. - **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration. @@ -61,24 +61,26 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - **Absent persistence degrades to live-only enumeration, not an error**: in a deployment without persistence, a cold child could not be resumed anyway, and listing live children remains meaningful. (Contrast: the old implementation rejected wholesale when sessionQuery was missing.) - A persistence listing failure fails the whole enumeration; per-child isolation applies only to the per-child cold reads. -### Value retrieval: the two-tier compute-and-discard ladder +### Value retrieval: the three-rung compute-and-discard ladder -For each enumerated child, mode/label retrieval walks a two-tier ladder, the same shape as apiproxy `session.history`'s cold read — compute-and-discard, no cache, no write-back: +For each enumerated child, mode/label retrieval walks a three-rung ladder — compute-and-discard, no cache of its own, no write-back (the third rung is the same shape as apiproxy `session.history`'s cold read): -| Tier | Read | Cost | +| Rung | Read | Cost | | --- | --- | --- | -| live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | -| cold child | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing | +| 1: live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | +| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly when `subagent` is among its values — identity is immutable once appended, so a served value is final regardless of the row's watermark | Zero log reads | +| 3: cold child, fallback | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing | - Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. +- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing. - Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping). - Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field. -- The cold-read cost, recorded honestly: a cold child pays one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache is built for it. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout. +- The cold-read cost, recorded honestly: only with the cache unmounted or missed does a cold child pay one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout. - Cancellation: the caller's signal is checked before and after each persistence read, and a read that settles only after abort is rejected, normalized to the stable error code `CANCELLED`. ### Authority model -- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints, no in-process memo. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read. +- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints of its own, no in-process memo; the `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which this design only reads and never writes. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read (identity is immutable, so a cached value has no staleness problem). - The Session and persistence write paths are entirely unaware of listing and projection consumption: no event-listener write-back, no fold-on-write. - Enumeration and value retrieval constitute no second authorization source and make no unpublished child visible — the two sources see only published live records and durably written persisted records, consistent with the rule the durable-subagent-catalog note laid down for derived read surfaces. @@ -132,7 +134,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir | Area | Files | Change | | --- | --- | --- | | subagent | projection.ts, projection-types.ts, index.ts | New `subagent` unit and its registration | -| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` | +| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) | | host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin` | | tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged | | wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | **Zero changes** — row shape and diagnostic handling unchanged | @@ -142,7 +144,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir **mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format. -**The projection-cache ladder (v3 draft: `cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But it hands the subagent domain a `sessionProjectionCache` dependency on top of `sessionProjections`, and checkpoints are a new body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); read-time computation needs no durable derivation at all. +**The projection-cache ladder (v3 draft: `cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent. **A bounded-read primitive on persistence to rescue pre-existing data.** Opens a new seam primitive for a one-time problem; superseded by the read-time `inspect` full read — the full read the first time pre-existing data is listed is itself the value retrieval. @@ -160,13 +162,13 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Verification -`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots. +`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Four second-rung cases: a real-composition cache hit with zero `inspect`, an absent in-row `subagent` key falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots. ## Consequences -- Listing a live child reads zero log throughout; a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it. +- Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it. - The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`. -- Identity interpretation exists only in the single unit registered with the registry: the list's two-tier ladder and GUI history's cold read use the same two reads (snapshot/restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. +- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the registry's and the cache's existing reads (snapshot, cachedSnapshot, restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. - Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. - The diagnostic and enumeration semantics leaves five boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, and an unknown parent yielding an empty list instead of not-found); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data with resume authorization unaffected, and the unknown-parent one is a silent query-semantics change, explicitly accepted. - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index 45bdd41ba0..ceb1f1ee2e 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -14,14 +14,14 @@ Status: implemented ## 决策 -mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走 live/cold 两级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读),cold child 一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、无缓存、无回写。 +mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。 消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。"值随查询索引落库"曾是本记录的定稿方向并一度施工,最终整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被"算完即止"显式接受。前两条与退役理由详见考虑过的替代方案一节。 要点: - **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 -- **取值两级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——无缓存、无回写、无索引。 +- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含 `subagent` 即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。 - **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 - **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 @@ -61,24 +61,26 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - **persistence 缺席退为 live-only 枚举,不报错**:没有 persistence 的部署,cold child 本就无法 resume,列出 live child 仍然有意义。(对照:旧实现在 sessionQuery 缺失时整体拒绝。) - persistence 列表失败使整次枚举失败;per-child 隔离只作用于逐 child 的冷读。 -### 取值:两级"算完即止"阶梯 +### 取值:三级"算完即止"阶梯 -对每个枚举出的 child,mode/label 取值走两级阶梯,与 apiproxy `session.history` 的冷读同款——算完即止,无缓存、无回写: +对每个枚举出的 child,mode/label 取值走三级阶梯——算完即止,不自建缓存、无回写(第三级与 apiproxy `session.history` 的冷读同款): | 级 | 读法 | 成本 | | --- | --- | --- | -| live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | -| cold child | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 | +| 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | +| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含 `subagent` 即直接用——身份一经追加不可变,读到即定值,无视行水位 | 零日志读 | +| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 | - 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 +- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判。 - per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。 - 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。 -- 冷读成本如实记录:cold child 每次列表一次整读,成本与其 transcript 大小成正比;定案"算完即止",不为它建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 +- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案"算完即止",不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 - 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。 ### 权威模型 -- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有 checkpoint、没有进程 memo,取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision。 +- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(身份不可变,缓存值无陈旧性问题)。 - Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。 - 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。 @@ -132,7 +134,7 @@ export type SubagentListEntry = | 区域 | 文件 | 改动 | | --- | --- | --- | | subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 | -| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` | +| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) | | host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin` | | tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | | wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | **零改动**——行形状与 diagnostic 处理不变 | @@ -142,7 +144,7 @@ export type SubagentListEntry = **mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是"第一次列表一次 `inspect` 现算",不碰持久格式。 -**projection-cache 阶梯(v3 稿:`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但它给 subagent 域在 `sessionProjections` 之外再引入 `sessionProjectionCache` 依赖,且 checkpoint 是一套新增的派生数据持久化与失效编排(floor/identity/putSoft);读时现算不需要任何持久派生。 +**projection-cache 阶梯(v3 稿:`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。 **给 persistence 加有界读原语抢救存量。** 为一次性问题新开 seam 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。 @@ -160,13 +162,13 @@ export type SubagentListEntry = ## 验证 -`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 +`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级四例:真组合 cache 命中零 `inspect`、行内 `subagent` key 缺席落底、cache 服务缺席落底、缓存行中毒静默落底重折。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 ## 后果 -- live child 的列表全程零日志读;cold child 每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案"算完即止",不建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 +- live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案"算完即止",不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 - subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。 -- 身份解释只存在于 registry 注册的一份 unit:列表两级阶梯与 GUI history 冷读走同两处读法(snapshot/restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 +- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 - per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 - 诊断与枚举语义留下五处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,恢复鉴权不受影响,未知 parent 一处是查询语义的静默变化,显式接受。 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index a18deee8eb..e790a473b8 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: e364572f9ac6a52acb118de0906cb5ec442536cc -subagent.zh.md: 4440c4f6a4212d0cf8a4d6367389f6973dfc5d17 +subagent.md: e96e3556334e7cee9b1a9386eafc81a2fdbce725 +subagent.zh.md: 4ca3f0707d78adc371708c18f586a9dfcb499347 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index e364572f9a..e96e355633 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -252,7 +252,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn ## Durable enumeration: `listChildren()` and `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served from the registry's watermark cache for a live child (zero log reads) and folded once over one `persistence.inspect()` reading for a cold one (bounded concurrency, recomputed per listing — no cache). The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent, checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). +`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — a served identity is final, because identity is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, missing the key, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent, checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). ## The terminal result: `SubagentResult` diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 4440c4f6a4..4ca3f0707d 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -252,7 +252,7 @@ interface ContinuableCreateSpec { ## 持久化枚举:`listChildren()` 与 `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值:存活 child 由注册表水位缓存同步供值(零日志读取),冷 child 在一次 `persistence.inspect()` 读取上折叠一次(有界并发,每次列表重新计算——无缓存)。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,并且在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 +`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——身份一经追加不可变,读到即定值);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、key 缺席或读取出错都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,并且在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 ## 终态结果:`SubagentResult` diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 72833b7ddb..b49fc06e25 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: bfed362d5a70bf946295c04d02ed1c6d031041e3 -README.zh.md: 11121735bd4acdfccf2ef950d30e5913646430a4 +README.md: b89fcb4b4d318c872117078b6c49822d0633fd31 +README.zh.md: 6309a9b8ad4ac9edf5ccf994594638cc9f175684 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index bfed362d5a..b89fcb4b4d 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -84,7 +84,7 @@ When `ctx.sessionProjections` is available, the service registers two projection ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child, one bounded-concurrency persistence inspection folded through the registry for a cold one. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache already serves the identity (immutable once appended, so staleness cannot matter), else one bounded-concurrency persistence inspection folded through the registry. A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 11121735bd..6309a9b8ad 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -84,7 +84,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照,冷 child 经一次有界并发的持久化 inspect 再经注册表折叠。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行——身份一经追加即不可变,故无须关心行的新旧——命中即用,否则经一次有界并发的持久化 inspect 再经注册表折叠。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 04ae941633..ba1dd0fbc4 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", + "@deepseek-ai/dsh-session-projection-cache": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -52,6 +53,9 @@ "@deepseek-ai/dsh-session-projection": { "optional": true }, + "@deepseek-ai/dsh-session-projection-cache": { + "optional": true + }, "@deepseek-ai/dsh-tasks": { "optional": true } @@ -65,6 +69,9 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 03e0bb3367..216ad4eca4 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -288,8 +288,11 @@ export class SubagentService extends Service { * resuming an Agent and without any query seam: the listing merges the live * session store with optional session persistence (live-preferred) and * serves each child's durable mode/label from the registered `subagent` - * projection unit — the registry's watermark snapshot for a live child, one - * persistence inspection folded through the registry for a cold one. The + * projection unit down a three-rung ladder — the registry's watermark + * snapshot for a live child; for a cold one, a durable projection-cache + * row when the optional cache already serves the identity (the value is + * immutable, so staleness cannot matter), else one persistence inspection + * folded through the registry. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, * never a list-time descriptor parse. Absent persistence, enumeration is diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 8116d3dd88..7bc7e0fdbf 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -3,9 +3,11 @@ * from the live session store and optional session persistence — no query * seam. Candidates are the live-preferred merge of both listings filtered to * durable `origin: 'subagent'` under the parent; each child's mode/label is - * the registered `subagent` projection unit's value, served from the - * registry's watermark cache for a live child and folded once over one - * persistence inspection for a cold one. The projection fold is the single + * the registered `subagent` projection unit's value, resolved down a + * three-rung ladder: the registry's watermark cache for a live child, a + * durable projection-cache row when the optional cache already serves the + * identity, and one persistence inspection folded through the registry + * otherwise. The projection fold is the single * classification authority — this module parses no descriptor itself. Absent * persistence, enumeration is live-only: a cold child is unreachable for * resume anyway, so its absence is capability absence, not an error. The @@ -19,6 +21,7 @@ import type { Context } from 'cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' import { SubagentError } from './error.ts' import type { SubagentIdentityProjection } from './projection-types.ts' @@ -88,11 +91,13 @@ export type SubagentListEntry = * Enumerate one parent's origin-classified direct children from the * live-preferred merge of `ctx.sessions` and optional session persistence, * serving each identity from the `subagent` projection unit: the registry's - * watermark snapshot for a live child, one bounded-concurrency persistence - * inspection folded through the registry for a cold one. + * watermark snapshot for a live child; for a cold one, a durable + * projection-cache row when the optional cache already serves the identity, + * else one bounded-concurrency persistence inspection folded through the + * registry. * @see SubagentService.listChildren for the public cancellation and failure contract. * @param ctx - context carrying the session store, the projection registry, - * and optional persistence. + * optional persistence, and the optional projection cache. * @param parentSessionId - parent session whose direct children are listed. * @param signal - caller-owned cancellation observed around every persistence read. * @returns children and per-child diagnostics ordered by `createdAt`, then id. @@ -126,6 +131,10 @@ export async function listChildren( } assertListingNotCancelled(signal) const persistence = ctx.get('sessionPersistence') + // Optional acceleration only: an absent cache service just means every + // cold candidate takes the authoritative preparation rung, so it carries + // no error code and no configuration check. + const cache = ctx.get('sessionProjectionCache') let persistedHeaders: readonly SessionHeader[] = [] if (persistence !== undefined) { try { @@ -158,11 +167,11 @@ export async function listChildren( || a.header.id.localeCompare(b.header.id)) const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length }) - const coldReads: { index: number; id: SessionId }[] = [] + const coldReads: { index: number; header: SessionHeader }[] = [] candidates.forEach((candidate, index) => { const childId = candidate.header.id if (candidate.live === undefined) { - coldReads.push({ index, id: childId }) + coldReads.push({ index, header: candidate.header }) return } // The registry's watermark cache serves the live value with zero log @@ -191,8 +200,9 @@ export async function listChildren( { length: Math.min(COLD_READ_CONCURRENCY, queue.length) }, async () => { for (let job = queue.shift(); job !== undefined; job = queue.shift()) { - rows[job.index] = await inspectColdIdentity( - persistence, projections, job.id, subagentParents.has(job.id), signal, + rows[job.index] = await resolveColdIdentity( + persistence, projections, cache, job.header, + subagentParents.has(job.header.id), signal, ) } }, @@ -203,20 +213,38 @@ export async function listChildren( } /** - * Resolve one cold candidate: one persistence inspection folded through the - * projection registry (the same detached recipe the API proxy uses for - * detached session projections). A failed inspection is one transient - * `unavailable` row retried on the next listing; a settled log the fold - * cannot identify — or that makes any registered unit throw — is final, so - * it reports `corrupt`. + * Resolve one cold candidate down the remaining ladder: a durable + * projection-cache row when it already serves the identity, otherwise one + * persistence inspection folded through the projection registry (the same + * detached recipe the API proxy uses for detached session projections). A + * failed inspection is one transient `unavailable` row retried on the next + * listing; a settled log the fold cannot identify — or that makes any + * registered unit throw — is final, so it reports `corrupt`. */ -async function inspectColdIdentity( +async function resolveColdIdentity( persistence: SessionPersistence, projections: SessionProjectionRegistry, - childId: SessionId, + cache: SessionProjectionCache | undefined, + header: SessionHeader, hasChildren: boolean, signal: AbortSignal | undefined, ): Promise<SubagentListEntry> { + const childId = header.id + if (cache !== undefined) { + let cached: SubagentIdentityProjection | undefined + try { + cached = cache.cachedSnapshot(header)?.values.subagent + } catch { + // Unlike the preparation fold below, a throwing cache read renders no + // verdict: the cache is derived data, so its damage (a poisoned stored + // row of ANY unit) silently falls through to the authoritative re-fold. + cached = undefined + } + // The identity is immutable once appended, so a cached value is final + // regardless of the row's watermark; an absent key (a checkpoint cut + // before the descriptor was appended) falls through to preparation. + if (cached !== undefined) return childRow(childId, cached, 'inactive', hasChildren) + } assertListingNotCancelled(signal) let events: readonly SessionEvent[] try { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 8745496d16..24e1abf689 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -12,6 +12,10 @@ import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache' +import Storage from '@deepseek-ai/dsh-storage' +import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' +import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError, @@ -28,7 +32,10 @@ afterEach(() => { }) /** Boot the continuable stack with real JSONL session persistence. */ -async function setup(script: Script, options: { sessionProjections?: boolean } = {}) { +async function setup( + script: Script, + options: { sessionProjections?: boolean; projectionCache?: boolean } = {}, +) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-')) @@ -36,6 +43,14 @@ async function setup(script: Script, options: { sessionProjections?: boolean } = await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry) + if (options.projectionCache === true) { + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend(new MemoryMediaPool())) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + } await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) @@ -606,6 +621,78 @@ describe('SubagentService.listChildren', () => { expect(inspected).not.toContain(liveId) }) + it('serves a cold child from the projection cache without any inspection', async () => { + const { ctx, parent } = await setup([textResponse('done')], { projectionCache: true }) + const childId = await startChild(ctx, parent, 'cached child') + // The child's turn/end and disposal are the cache's mandatory checkpoint + // points; both writes are fail-soft asynchronous, so wait for the row. + const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId) + await vi.waitFor(() => { + expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined() + }, { timeout: 5_000 }) + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ + kind: 'child', id: childId, label: 'cached child', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }]) + expect(inspect).not.toHaveBeenCalled() + }) + + it('falls back to inspection when the cache serves no identity for the child', async () => { + const { ctx, parent } = await setup([], { projectionCache: true }) + const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac01', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('uncached child'))) + const expected = [{ + kind: 'child', id: foreign, label: 'uncached child', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }] + // No stored row at all for a foreign child this process never ran. + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected) + expect(inspect).toHaveBeenCalledTimes(1) + // A stored row whose cut predates the descriptor: the subagent key is + // absent from the served values, and preparation still rules. + ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: {} }) + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected) + expect(inspect).toHaveBeenCalledTimes(2) + }) + + it('takes the preparation rung directly when no projection cache is mounted', async () => { + const { ctx, parent } = await setup([]) + expect(ctx.get('sessionProjectionCache')).toBeUndefined() + const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac02', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('uncacheable child'))) + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ + kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }]) + expect(inspect).toHaveBeenCalledTimes(1) + }) + + it('silently falls through to preparation when the cache read throws', async () => { + const { ctx, parent } = await setup([], { projectionCache: true }) + const recovered = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac03', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('recovered child'))) + ctx.sessionProjectionCache.cachedSnapshot = () => { + // A poisoned stored row (any unit's) detonates at view time; the cache + // is derived data, so its failure must not become a verdict. + throw new Error('poisoned cache row') + } + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ + kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }]) + expect(inspect).toHaveBeenCalledTimes(1) + }) + it('does not count an ordinary grandchild without subagent origin', async () => { const { ctx, parent } = await setup([textResponse('done')]) const childId = await startChild(ctx, parent, 'direct child') diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 5bb065571f..de2fff3d84 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../session-projection/session-projection-cache" + }, { "path": "../../tasks/tasks" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a2ba96a54..a9f111240a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5193,6 +5193,15 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session-projection/session-projection-cache + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain '@deepseek-ai/dsh-tasks': specifier: workspace:^ version: link:../../tasks/tasks From 397d70d545f519f142eaf90e993c68174f01d290 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:28:32 +0800 Subject: [PATCH 164/190] fix: address review round three - the subagent projection's empty state becomes a serializable null sentinel (undefined fields vanish in JSON push frames, leaving stale identities in clients); consumers treat null and a missing key alike, and cached sentinels defer to the authoritative refold - the tool catalog generator mounts the projection registry for list_agents; the shared unavailable-error copy goes catalog-neutral - the design note's verification section records the new subagent-diagnostic snapshot honestly, the 2026-07-22 note's test inventory is rewritten to the current mechanism, and the session-store error code is documented --- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 22 +++---- ...06-subagent-list-identity-projection.zh.md | 22 +++---- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 7 ++- ...ble-subagent-catalog-and-list-agents.zh.md | 7 ++- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- docs/tool-catalog.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/list-children.ts | 24 +++++--- .../subagent/subagent/src/projection-types.ts | 13 ++-- packages/subagent/subagent/src/projection.ts | 25 ++++---- .../subagent/tests/list-children.spec.ts | 61 +++++++++++++++++++ scripts/gen-tool-catalog.ts | 7 ++- 20 files changed, 146 insertions(+), 74 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 313dcf836e..fc666f437e 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: feea6a6634724c59a38bfbdc4c5dc138335899ec -2026-08-06-subagent-list-identity-projection.zh.md: ceb1f1ee2ea2856d073bf1718a651a162233d686 +2026-08-06-subagent-list-identity-projection.md: 008e7907bd35e7341e0ce7cb8f8541435973c8ea +2026-08-06-subagent-list-identity-projection.zh.md: 0f5876485c6e204db73810eb09314505039b2ebb diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index feea6a6634..008e7907bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -21,7 +21,7 @@ There are three families of escape from the per-child scan: promote mode/label i Key points: - **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual. -- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when `subagent` is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index. +- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when a non-null `subagent` identity is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index. - **The `subagent` projection unit is the sole authority over the fold rules**: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists. - **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration. @@ -41,14 +41,14 @@ export type SubagentIdentityProjection = declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { - subagent: SubagentIdentityProjection + subagent: SubagentIdentityProjection | null } } ``` -- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is "no value", and the key is absent on that session. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). +- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a **serializable null sentinel**: the map entry is `SubagentIdentityProjection | null`, non-optional, never undefined or an absent key. The reason: the registry's onChanged push goes through JSON serialization, where an undefined field is dropped by stringify, the client's frame validation rejects the frame, and a consumer's stored old identity would never update; null passes frames intact, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). - Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; this discriminant matches the child row's strong mode/label contract below exactly. -- Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to no value rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. +- Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. ### Enumeration: subagent-owned live-preferred merge @@ -68,11 +68,11 @@ For each enumerated child, mode/label retrieval walks a three-rung ladder — co | Rung | Read | Cost | | --- | --- | --- | | 1: live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | -| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly when `subagent` is among its values — identity is immutable once appended, so a served value is final regardless of the row's watermark | Zero log reads | +| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly when a non-null `subagent` identity is among its values — identity is immutable once appended, so a served value is final regardless of the row's watermark | Zero log reads | | 3: cold child, fallback | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing | -- Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. -- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing. +- Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. The session store gets the same posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. +- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. - Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping). - Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field. - The cold-read cost, recorded honestly: only with the cache unmounted or missed does a cold child pay one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout. @@ -110,9 +110,9 @@ For each enumerated child, the ladder's result maps to a row through four states | Ladder result | Row | | --- | --- | -| Snapshot carries a `subagent` value | child row | -| Snapshot present, value absent, and the child is **inactive** | diagnostic row, reason `corrupt` (settled debris: a missing, corrupt, or unrecognized-version descriptor, no longer subdivided) | -| Snapshot present, value absent, and the child is **running** | no row (creation window: the descriptor is not yet appended — the same window the old implementation omitted) | +| Snapshot carries a non-null `subagent` identity | child row | +| Snapshot present, `subagent` null sentinel or key absent, and the child is **inactive** | diagnostic row, reason `corrupt` (settled debris: a missing, corrupt, or unrecognized-version descriptor, no longer subdivided) | +| Snapshot present, `subagent` null sentinel or key absent, and the child is **running** | no row (creation window: the descriptor is not yet appended — the same window the old implementation omitted) | | The cold full read fails | diagnostic row, reason `unavailable` | - `unsupported` is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced. @@ -162,7 +162,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Verification -`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Four second-rung cases: a real-composition cache hit with zero `inspect`, an absent in-row `subagent` key falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the keyless ACP snapshots (`subagent-list-agents` among others) are not re-recorded — zero change to the wire and model-visible surfaces is pinned by the existing snapshots. +`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Four second-rung cases: a real-composition cache hit with zero `inspect`, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index ceb1f1ee2e..0f5876485c 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -21,7 +21,7 @@ mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠 要点: - **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 -- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含 `subagent` 即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。 +- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。 - **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 - **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 @@ -41,14 +41,14 @@ export type SubagentIdentityProjection = declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { - subagent: SubagentIdentityProjection + subagent: SubagentIdentityProjection | null } } ``` -- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果就是"无值",该 key 在这个 session 上缺席。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 +- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:registry 的 onChanged 推送经 JSON 序列化,undefined 字段被 stringify 丢弃,客户端帧校验拒收,消费方存储的旧身份将永不更新;null 完好过帧,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 - label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;该判别式与下文 child 行的 mode/label 强契约完全一致。 -- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为无值而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 +- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 ### 枚举:subagent 自管 live-preferred 合并 @@ -68,11 +68,11 @@ declare module '@deepseek-ai/dsh-session-projection/types' { | 级 | 读法 | 成本 | | --- | --- | --- | | 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | -| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含 `subagent` 即直接用——身份一经追加不可变,读到即定值,无视行水位 | 零日志读 | +| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份即直接用——身份一经追加不可变,读到即定值,无视行水位 | 零日志读 | | 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 | -- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 -- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判。 +- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 +- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。 - per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。 - 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。 - 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案"算完即止",不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 @@ -110,9 +110,9 @@ export type SubagentListEntry = | 阶梯取值结果 | 行 | | --- | --- | -| 快照含 `subagent` 值 | child 行 | -| 快照在、值缺席,且 child **inactive** | diagnostic 行,reason `corrupt`(定局残骸:无、损坏或版本不认识的描述符,不再细分) | -| 快照在、值缺席,且 child **running** | 行不出现(创建窗口:描述符尚未追加,与旧实现同窗口 omit) | +| 快照含非 null 的 `subagent` 身份 | child 行 | +| 快照在、`subagent` 为 null 哨兵或 key 缺席,且 child **inactive** | diagnostic 行,reason `corrupt`(定局残骸:无、损坏或版本不认识的描述符,不再细分) | +| 快照在、`subagent` 为 null 哨兵或 key 缺席,且 child **running** | 行不出现(创建窗口:描述符尚未追加,与旧实现同窗口 omit) | | cold 整读失败 | diagnostic 行,reason `unavailable` | - `unsupported` 不再被产出:类型与 wire 枚举按"数据结构保持现状"留存该成员,本记录留档其为不再产出。 @@ -162,7 +162,7 @@ export type SubagentListEntry = ## 验证 -`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级四例:真组合 cache 命中零 `inspect`、行内 `subagent` key 缺席落底、cache 服务缺席落底、缓存行中毒静默落底重折。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;无密钥 ACP 快照(`subagent-list-agents` 等)未重录——wire 与 model-visible 面零改动由既有快照钉住。 +`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级四例:真组合 cache 命中零 `inspect`、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index 2b316aba2e..a4037b2043 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: 12be9152edc1972337f96c098c8f7d93b723530c -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 7dee4ca59ff6dd1ace32e6779dec240038d2c483 +2026-07-22-durable-subagent-catalog-and-list-agents.md: 9515cf744706765dc2a5f34311198d2432d22924 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 25b019713c143c268cb10bd3a3dd0d774f97ad8f diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 12be9152ed..9515cf7447 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -93,9 +93,10 @@ The first version has no child deletion operation. If later product behavior del ## Testing - `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics. -- `packages/subagent/subagent/tests/list-children.spec.ts` pins a query-only composition with sessions, `subagents`, and `sessionQuery` but no `agents`, then drives the full real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: one-shot and continuable children from one real trace; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `inactive`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; forwarded trace/exact-read cancellation with stable `CANCELLED` normalization; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface. -- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, load-time `sessionQuery` injection, and HMR disposal. -- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, session query, and JSONL persistence, rendering `<id> [complete] — <label>`. +- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; and typed stable error codes. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface. +- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, the narrowed load requirement without `sessionQuery`, and HMR disposal. +- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, the projection registry, and JSONL persistence, rendering `<id> [complete] — <label>`. +- The keyless snapshot scenario `subagent-diagnostic` (examples/headless-agent) pins the current listing's model-visible diagnostic classification, including a descriptor-less settled child surfacing as a `corrupt` diagnostic. - The keyless ACP snapshot scenario `subagent-published-run-failure` publishes a real one-shot child, injects independent run-result and handle-disposal failures, and preserves both diagnostics in the parent tool result. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index 7dee4ca59f..25b019713c 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -93,9 +93,10 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导 ## 测试 - `packages/subagent/subagent/tests/service.spec.ts` 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。`packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id,并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递,并保留相互独立的结果与 dispose diagnostic。 -- `packages/subagent/subagent/tests/list-children.spec.ts` 先固定一个只有会话、`subagents` 和 `sessionQuery` 而没有 `agents` 的纯查询组合,再以无密钥方式驱动完整真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、subagent 服务,以及一个具体的会话查询服务):来自同一真实追踪的一次性与可继续 child;只存在于持久化存储中(重启形态)的 parent 目标;带有人工构造并列项的按 `createdAt` 再按 id 排序;排除普通 fork 和 fork seed 中祖先描述符且不产生 diagnostic;存活 `running` 与持久化 `inactive` 的对比;重复描述符、载荷格式错误、无效 surface、header 不匹配和读取目标已变化的损坏 diagnostic 均不隐藏健康的 sibling;不受支持版本与逐 child unavailable diagnostic;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;排除孙代会话;追踪阶段失败导致整次调用失败而候选阶段失败只隔离到单个 child;配置/窗口错误和无法识别的失败作为操作失败向上传播;转发 trace/精确读取取消并稳定归一化为 `CANCELLED`;以及 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 缺服务契约。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。 -- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、child/diagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、工具取消信号的转发、无调用 agent 时的拒绝、加载时的 `sessionQuery` 注入,以及 HMR dispose。 -- 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、会话查询和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`。 +- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;以及带类型的稳定错误码。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。 +- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、child/diagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、工具取消信号的转发、无调用 agent 时的拒绝、收窄后的加载要求(不再注入 `sessionQuery`),以及 HMR dispose。 +- 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`。 +- 无密钥快照场景 `subagent-diagnostic`(examples/headless-agent)钉住现行列表的模型可见诊断分类,包括无描述符的定局 child 以 `corrupt` diagnostic 出现。 - 无密钥 ACP 快照场景 `subagent-published-run-failure` 会发布一个真实的一次性 child,注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。 ## 影响 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index e790a473b8..644634044a 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: e96e3556334e7cee9b1a9386eafc81a2fdbce725 -subagent.zh.md: 4ca3f0707d78adc371708c18f586a9dfcb499347 +subagent.md: 514963706dedbf88c519c8c83aaf2bd6954fe492 +subagent.zh.md: 9835e8881841a0fbe682a2645731344265bfa762 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index e96e355633..514963706d 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -252,7 +252,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn ## Durable enumeration: `listChildren()` and `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — a served identity is final, because identity is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, missing the key, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent, checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). +`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — a served identity is final, because identity is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). ## The terminal result: `SubagentResult` diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 4ca3f0707d..9835e88818 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -252,7 +252,7 @@ interface ContinuableCreateSpec { ## 持久化枚举:`listChildren()` 与 `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——身份一经追加不可变,读到即定值);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、key 缺席或读取出错都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,并且在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 +`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——身份一经追加不可变,读到即定值);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 ## 终态结果:`SubagentResult` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 65a2760517..735fe07281 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -32,7 +32,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. | -| `@deepseek-ai/dsh-tool-subagent-control` | `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.sessionQuery (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query). | +| `@deepseek-ai/dsh-tool-subagent-control` | `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.sessionProjections (list_agents catalog rows)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | @@ -1232,7 +1232,7 @@ Send a message to a background subagent by its subagent id, continuing the same Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) -The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query). +The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry). ## `@deepseek-ai/dsh-tool-subagent-report` diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 835ca39728..5b4e1e7b65 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -571,7 +571,7 @@ function subagentPromptError( function projectionsUnavailableError(): RpcError { return { code: 'internal', - message: 'subagent listing is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', details: {}, } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index df0a8d53a3..6503fab3cc 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -164,7 +164,7 @@ describe('subagent gateway', () => { ) const expected = { code: 'internal', - message: 'subagent listing is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', } const list = bench({ listError: listError() }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index b49fc06e25..1de57e2fae 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: b89fcb4b4d318c872117078b6c49822d0633fd31 -README.zh.md: 6309a9b8ad4ac9edf5ccf994594638cc9f175684 +README.md: 92c8222381338f71c7d444da80c23098aba247b3 +README.zh.md: 8f61379bb789de6c1b97b22db034698009d0619c diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index b89fcb4b4d..92c8222381 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -78,7 +78,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. -When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to no value, indistinguishable from a log with no descriptor, and never throws. +When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws. `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 6309a9b8ad..8f61379bb7 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -78,7 +78,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 -当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为无值,与没有描述符的日志不可区分,且绝不抛错。 +当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中过时的身份而非永久滞留——绝不抛错。 `registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 7bc7e0fdbf..097c3b1019 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -177,7 +177,7 @@ export async function listChildren( // The registry's watermark cache serves the live value with zero log // reads; a live child without an identity yet is the creation window // before the establishing provider appends its descriptor. - let identity: SubagentIdentityProjection | undefined + let identity: SubagentIdentityProjection | null | undefined try { identity = projections.snapshot(candidate.live).values.subagent } catch { @@ -188,7 +188,9 @@ export async function listChildren( rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' } return } - if (identity === undefined) return + // The unit's serializable no-value sentinel is `null`; `undefined` can + // only mean the key was dropped at a JSON boundary. Both are no value. + if (identity === undefined || identity === null) return rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId)) }) @@ -231,7 +233,7 @@ async function resolveColdIdentity( ): Promise<SubagentListEntry> { const childId = header.id if (cache !== undefined) { - let cached: SubagentIdentityProjection | undefined + let cached: SubagentIdentityProjection | null | undefined try { cached = cache.cachedSnapshot(header)?.values.subagent } catch { @@ -240,10 +242,14 @@ async function resolveColdIdentity( // row of ANY unit) silently falls through to the authoritative re-fold. cached = undefined } - // The identity is immutable once appended, so a cached value is final - // regardless of the row's watermark; an absent key (a checkpoint cut - // before the descriptor was appended) falls through to preparation. - if (cached !== undefined) return childRow(childId, cached, 'inactive', hasChildren) + // A served identity is immutable once appended, so a cached one is final + // regardless of the row's watermark. Both no-value forms fall through to + // preparation: an absent key (a checkpoint cut before the descriptor was + // appended) and the `null` sentinel, whose verdict belongs to the + // authoritative re-fold, not to a derived row. + if (cached !== undefined && cached !== null) { + return childRow(childId, cached, 'inactive', hasChildren) + } } assertListingNotCancelled(signal) let events: readonly SessionEvent[] @@ -256,7 +262,7 @@ async function resolveColdIdentity( return { kind: 'diagnostic', id: childId, reason: 'unavailable' } } assertListingNotCancelled(signal) - let identity: SubagentIdentityProjection | undefined + let identity: SubagentIdentityProjection | null | undefined try { identity = projections.restore({}, events, 0).snapshot.values.subagent } catch { @@ -265,7 +271,7 @@ async function resolveColdIdentity( // damage in this one child, contained as its own corrupt diagnostic. return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } - if (identity === undefined) { + if (identity === undefined || identity === null) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } return childRow(childId, identity, 'inactive', hasChildren) diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index a92ed3a882..2d40126427 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -42,11 +42,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' { /** Active-turn duration for a descriptor-backed subagent session. */ subagentTiming: SubagentTimingProjection /** - * Identity of a descriptor-backed subagent session. No value ⟺ no valid - * descriptor: a missing, malformed, or unrecognized-version descriptor is - * served identically as `undefined` in a live snapshot, and as an absent - * key after any JSON boundary (query-index rows, wire frames) drops it. + * Identity of a descriptor-backed subagent session. `null` ⟺ no valid + * descriptor (missing, malformed, or unrecognized-version — deliberately + * undistinguished). The sentinel is deliberately serializable: a + * value pushed over JSON transports must survive `JSON.stringify` + * losslessly, where an `undefined` field would be dropped and a stale + * identity would survive on the receiving side. The entry itself stays + * non-optional. */ - subagent: SubagentIdentityProjection + subagent: SubagentIdentityProjection | null } } diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 5fa5d70ab8..f57af14ffa 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -90,9 +90,11 @@ interface IdentityState { identity?: SubagentIdentityProjection } -// Zod's optional output includes explicit `undefined`; with -// exactOptionalPropertyTypes the public map entry permits omission only, and -// JSON boundaries drop the undefined-valued key entirely. +// The cast bridges only the optional-label arm: Zod's optional output +// includes explicit `undefined`, which exactOptionalPropertyTypes excludes +// from the public interface. The no-value state itself is the serializable +// `null` arm — never `undefined` — so every registry read and push frame +// survives JSON.stringify losslessly. const identitySchema = z.discriminatedUnion('mode', [ z.object({ mode: z.literal('one-shot'), @@ -102,7 +104,7 @@ const identitySchema = z.discriminatedUnion('mode', [ mode: z.literal('continuable'), label: z.string(), }).strict(), -]).optional() as unknown as z.ZodType<SubagentIdentityProjection> +]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null> /** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined { @@ -125,9 +127,11 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u * last-wins: a fork seed may replay an ancestor's descriptor, and the child's * own descriptor must override it — the same reset discipline as * {@link subagentTimingProjectionDefinition}. A malformed or unknown-version - * payload resets to no value instead of throwing, so a fork of a healthy - * ancestor never inherits an identity its own descriptor failed to establish; - * no value ⟺ no valid descriptor, with the causes deliberately undistinguished. + * payload resets to the `null` sentinel instead of throwing, so a fork of a + * healthy ancestor never inherits an identity its own descriptor failed to + * establish — and the reset survives every JSON push frame, so a consumer + * holding the earlier identity replaces it instead of keeping it stale; + * `null` ⟺ no valid descriptor, with the causes deliberately undistinguished. */ export const subagentIdentityProjectionDefinition: ProjectionDefinition<'subagent', IdentityState> = { @@ -139,11 +143,6 @@ ProjectionDefinition<'subagent', IdentityState> = { const identity = descriptorIdentity(event) return identity === undefined ? {} : { identity } }, - // The assertion deliberately widens: a log without a descriptor serves - // `undefined` at runtime, which the schema's `.optional()` accepts, and - // every registry read face already returns `Partial` snapshot values where - // absence is the type. The map entry stays non-optional so a child row's - // served identity remains a strong contract for consumers. - view: state => state.identity as SubagentIdentityProjection, + view: state => state.identity ?? null, stateVersion: 1, } diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 24e1abf689..2dba308c7f 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -364,6 +364,67 @@ describe('SubagentService.listChildren', () => { }) }) + it('serves the serializable null sentinel when a later descriptor invalidates the identity', async () => { + const { ctx, parent } = await setup([]) + const liveId = SessionId('invalidated-live-child') + const live = ctx.sessions.create(liveId, { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + live.append('turn/start', { turn: 1 }) + live.append('subagent/descriptor', descriptorPayload('was valid')) + expect(ctx.sessionProjections.snapshot(live).values.subagent) + .toEqual({ mode: 'continuable', label: 'was valid' }) + // Last-wins: the malformed follow-up resets the identity to the sentinel. + live.append( + 'subagent/descriptor', + { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 } as never, + ) + const values = ctx.sessionProjections.snapshot(live).values + expect(values.subagent).toBeNull() + // The sentinel survives a JSON push frame; an undefined field would be + // dropped there and a consumer would keep the stale identity forever. + const wired = JSON.parse(JSON.stringify(values)) as Record<string, unknown> + expect('subagent' in wired).toBe(true) + expect(wired['subagent']).toBeNull() + // The listing reads the same null as no value: running → omitted. + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([]) + }) + + it('diagnoses a settled child whose later descriptor invalidated the identity as corrupt', async () => { + const { ctx, parent } = await setup([]) + const events = childEvents(descriptorPayload('was valid')) + events.splice(3, 0, { + type: 'subagent/descriptor', + seq: 3, + time: 3, + data: { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }, + } as SessionEvent) + events[4] = { ...events[4]!, seq: 4 } + const invalidated = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad01', { + parentSession: parent.id, + origin: 'subagent', + }, events) + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([ + { kind: 'diagnostic', id: invalidated, reason: 'corrupt' }, + ]) + }) + + it('lets preparation rule when the cache serves the null sentinel', async () => { + const { ctx, parent } = await setup([], { projectionCache: true }) + const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('actually valid'))) + // A stale cached sentinel must not out-rank the authoritative re-fold. + ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } }) + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ + kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }]) + expect(inspect).toHaveBeenCalledTimes(1) + }) + it('maps a child rejected by persistence inspection to unavailable', async () => { const { ctx, parent } = await setup([]) // The surface-eligible user/message lacks its required surfaceOp, so the diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index f0eb64548e..dd8dd9fe5f 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -14,6 +14,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -401,19 +402,19 @@ const TOOL_PACKAGES: ToolPackage[] = [ list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts', send_message: 'packages/subagent/tool-subagent-control/src/index.ts', }, - requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionQuery (list_agents only)'], + requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionProjections (list_agents catalog rows)'], writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'], async mount(ctx) { await ctx.plugin(SubagentService) await ctx.plugin(LocalTaskService) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) - await ctx.plugin(SessionQuerySqlite, { path: ':memory:' }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(ToolSubagentControl) await ctx.plugin(ToolSubagentListAgents) }, note: - 'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query).', + 'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry).', }, { pkg: '@deepseek-ai/dsh-tool-subagent-report', From ba791c0ac3516f6ccfc6f7e9f53fb87a3a066351 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:52:44 +0800 Subject: [PATCH 165/190] fix: ci --- docs/cordis-catalog/services.md | 7 +++++-- docs/module-graph.md | 3 ++- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d4c944ef9d..411aed5fad 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2103,8 +2103,11 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise<void> * resuming an Agent and without any query seam: the listing merges the live * session store with optional session persistence (live-preferred) and * serves each child's durable mode/label from the registered `subagent` - * projection unit — the registry's watermark snapshot for a live child, one - * persistence inspection folded through the registry for a cold one. The + * projection unit down a three-rung ladder — the registry's watermark + * snapshot for a live child; for a cold one, a durable projection-cache + * row when the optional cache already serves the identity (the value is + * immutable, so staleness cannot matter), else one persistence inspection + * folded through the registry. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, * never a list-time descriptor parse. Absent persistence, enumeration is diff --git a/docs/module-graph.md b/docs/module-graph.md index 0085b96f34..a50b658e2a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -757,6 +757,7 @@ flowchart TD pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence pkg_subagent --> pkg_session_projection + pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools pkg_tool_web --> pkg_invariants @@ -1243,7 +1244,7 @@ flowchart TD | [`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) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`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), [`tasks`](../packages/tasks/tasks), [`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-projection-cache`](../packages/session-projection/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7c21ac00d1..86b63052da 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -938,7 +938,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>', - jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit — the registry\'s watermark snapshot for a live child, one\n * persistence inspection folded through the registry for a cold one. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */', + jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache already serves the identity (the value is\n * immutable, so staleness cannot matter), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */', }, { signature: 'registerProvider(provider: SubagentProvider): () => void', From b9886d161570ea1885d8b58704b3076116d46be9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:17:19 +0800 Subject: [PATCH 166/190] refactor(apiproxy): read subagent history like session history subagents.history drops its session-query read: a live child serves its in-memory events with the registry watermark snapshot, a cold child goes through inspectServable with a detached fold, and the retired SESSION_QUERY_* arms disappear while every wire face stays put. The design note records the shared read source. --- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 6 +- ...06-subagent-list-identity-projection.zh.md | 6 +- packages/host/apiproxy/src/api-proxy.ts | 79 ++++++++++++------- packages/host/apiproxy/src/api/subagents.ts | 3 +- .../tests/api-proxy-subagents.spec.ts | 76 ++++++++++++------ 6 files changed, 111 insertions(+), 63 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index fc666f437e..cb76aaf7b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 008e7907bd35e7341e0ce7cb8f8541435973c8ea -2026-08-06-subagent-list-identity-projection.zh.md: 0f5876485c6e204db73810eb09314505039b2ebb +2026-08-06-subagent-list-identity-projection.md: 0684ec284fbce161e37a9f7c7c57460d91c8d0d0 +2026-08-06-subagent-list-identity-projection.zh.md: ea5fb4329032e42bf394706032e8571d5a975e2e diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index 008e7907bd..0684ec284f 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -127,7 +127,7 @@ Known boundary deviations (deliberately accepted, recorded with this note): - A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). - An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. -Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral change is the apiproxy route segment: the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this. +Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). ### Change footprint @@ -135,9 +135,9 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir | --- | --- | --- | | subagent | projection.ts, projection-types.ts, index.ts | New `subagent` unit and its registration | | subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) | -| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin` | +| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms retired with it | | tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged | -| wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | **Zero changes** — row shape and diagnostic handling unchanged | +| wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | Types, row shape, and diagnostic handling **unchanged**; api/subagents.ts only reworded the `history` JSDoc to the dual arm | | core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** | ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index 0f5876485c..ea5fb43290 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -127,7 +127,7 @@ export type SubagentListEntry = - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 - 未知 parent,旧实现经 session-query 抛 not-found('parent session … was not found');现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 -消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上唯一动的是 apiproxy 路由段:`hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 +消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 ### 改动落点 @@ -135,9 +135,9 @@ export type SubagentListEntry = | --- | --- | --- | | subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 | | subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) | -| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin` | +| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役 | | tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | -| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | **零改动**——行形状与 diagnostic 处理不变 | +| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | 类型、行形状与 diagnostic 处理**零改动**;api/subagents.ts 仅 `history` 的 JSDoc 措辞改为双臂 | | core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** | ## 考虑过的替代方案 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5b4e1e7b65..d9ed8115f2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1918,44 +1918,63 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro parentSessionId, childSessionId, mode, }, signal) if (verified.error !== undefined) return err(request, verified.error) - try { - const snapshot = await ctx.sessionQuery.readSession(childSessionId) - signal?.throwIfAborted() - if (snapshot.session.parentSession !== parentSessionId) { + // The generic-history data plane: an attached child serves its + // in-memory snapshot and the registry's live watermark projections; a + // cold child is one persistence inspection plus a detached fold. + let header: SessionHeader + let events: SessionEvent[] + let projections: SessionProjectionsBlock | undefined + const attached = ctx.sessions.get(childSessionId) + if (attached !== undefined) { + header = attached.header + events = [...attached.events] + projections = beforeSeq === undefined ? projectionsFor(ctx, attached) : undefined + } else { + try { + const inspected = await inspectServable(childSessionId) + header = inspected.meta + events = inspected.events + projections = beforeSeq === undefined + ? detachedProjectionsFor(ctx, inspected.events) + : undefined + } catch (error: unknown) { + if (signal?.aborted) { + return err(request, { + code: 'cancelled', + message: 'subagent history read was cancelled', + details: {}, + }) + } + if (error instanceof SessionNotFound) { + return err(request, { + code: 'subagent-not-found', + message: 'subagent disappeared during history read', + details: { parentSessionId, childSessionId }, + }) + } return err(request, { - code: 'subagent-unauthorized', - message: 'subagent parent changed during history read', - details: { childSessionId }, - }) - } - const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages) - const projections = beforeSeq === undefined - ? detachedProjectionsFor(ctx, snapshot.events) - : undefined - return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) - } catch (error: unknown) { - if (signal?.aborted - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { - return err(request, { - code: 'cancelled', - message: 'subagent history read was cancelled', + code: 'internal', + message: 'subagent history read failed', details: {}, }) } - if (error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return err(request, { - code: 'subagent-not-found', - message: 'subagent disappeared during history read', - details: { parentSessionId, childSessionId }, - }) - } + } + if (signal?.aborted) { return err(request, { - code: 'internal', - message: 'subagent history read failed', + code: 'cancelled', + message: 'subagent history read was cancelled', details: {}, }) } + if (header.parentSession !== parentSessionId) { + return err(request, { + code: 'subagent-unauthorized', + message: 'subagent parent changed during history read', + details: { childSessionId }, + }) + } + const page = historyPage(ctx, events, beforeSeq, maxMessages) + return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) }, async prompt(request, signal) { diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index d1e0392eb1..4c251dca7c 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -70,7 +70,8 @@ export interface SubagentsApi { ): Promise<RpcResponse<SubagentCatalog>> /** - * Reads one healthy catalog child's persisted raw log with ordinary + * Reads one healthy catalog child's transcript — the in-memory snapshot of + * a live child, the persisted log of a cold one — with ordinary * message-aligned pagination and render intents, without Agent activation. */ history( diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index 6503fab3cc..691b97b7b4 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import { RpcId } from '../src/api/rpc.ts' import type { RpcRequest } from '../src/api/rpc.ts' @@ -21,7 +20,10 @@ function bench(options: { entries?: object[] followupError?: Error listError?: Error - readError?: Error + /** Persistence forgets the child entirely (the vanished-mid-read race). */ + storedChild?: false + /** Attach the child to the live session store instead of persistence only. */ + liveChild?: true historyParent?: SessionId } = {}) { const parent = { id: PARENT } @@ -49,25 +51,38 @@ function bench(options: { ) => options.followupError === undefined ? Promise.resolve('message-1') : Promise.reject(options.followupError)) - const readSession = vi.fn(() => options.readError === undefined - ? Promise.resolve({ - session: { - version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT, - } satisfies SessionHeader, - events: [ - { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } }, - ] as unknown as SessionEvent[], - }) - : Promise.reject(options.readError)) + const childHeader = { + version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT, + } satisfies SessionHeader + const childEvents = [ + { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } }, + ] as unknown as SessionEvent[] + const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents })) + const liveBlock = { values: {}, asOfSeq: 3 } + const coldBlock = { values: {}, asOfSeq: 0 } + const snapshot = vi.fn(() => liveBlock) + const restore = vi.fn(() => ({ snapshot: coldBlock })) const ctx = new Context() ctx.provide('agents', { get: getAgent }) ctx.provide('subagents', { listChildren, followup }) - ctx.provide('sessionQuery', { readSession }) + ctx.provide('sessions', { + get: (id: SessionId) => options.liveChild === true && id === CHILD + ? { id: CHILD, header: childHeader, events: childEvents } + : undefined, + }) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]), + inspect, + locate: () => undefined, + }) + // The gateway's own projection push feed subscribes at construction; the + // no-op disposer keeps that seam quiet while these tests pin history reads. + ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', }) - return { api, getAgent, listChildren, readSession, followup, parent } + return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } } describe('subagent gateway', () => { @@ -113,7 +128,7 @@ describe('subagent gateway', () => { }) it('reads a healthy direct child without looking up or activating any Agent', async () => { - const { api, getAgent, readSession } = bench() + const { api, getAgent, inspect, restore } = bench() const response = await api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10, })) @@ -121,27 +136,42 @@ describe('subagent gateway', () => { ok: true, value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, }) - expect(readSession).toHaveBeenCalledWith(CHILD) + expect(inspect).toHaveBeenCalledWith(CHILD) + expect(restore).toHaveBeenCalledTimes(1) expect(getAgent).not.toHaveBeenCalled() }) + it('serves a live child from the in-memory snapshot and the watermark projections', async () => { + const { api, inspect, snapshot, restore } = bench({ liveChild: true }) + const response = await api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + })) + expect(response.result).toMatchObject({ + ok: true, + value: { hasMore: false, projections: { asOfSeq: 3 } }, + }) + expect(snapshot).toHaveBeenCalledTimes(1) + expect(restore).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() + }) + it('reads one-shot history and rejects an address with the wrong mode', async () => { const oneShot = { kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', activity: 'inactive', hasChildren: false, } - const { api, readSession } = bench({ entries: [oneShot] }) + const { api, inspect } = bench({ entries: [oneShot] }) expect((await api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot', }))).result).toMatchObject({ ok: true }) expect((await api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', }))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } }) - expect(readSession).toHaveBeenCalledTimes(1) + expect(inspect).toHaveBeenCalledTimes(1) }) it('rejects a diagnostic address before reading history', async () => { - const { api, readSession } = bench({ entries: [ + const { api, inspect } = bench({ entries: [ { kind: 'diagnostic', id: CHILD, reason: 'unsupported' }, ] }) const response = await api.subagents.history(request({ @@ -154,7 +184,7 @@ describe('subagent gateway', () => { details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' }, }, }) - expect(readSession).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() }) it('maps the missing projections capability to one wire face on list, history, and prompt', async () => { @@ -175,7 +205,7 @@ describe('subagent gateway', () => { expect((await history.api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', }))).result).toMatchObject({ ok: false, error: expected }) - expect(history.readSession).not.toHaveBeenCalled() + expect(history.inspect).not.toHaveBeenCalled() const prompt = bench({ listError: listError() }) expect((await prompt.api.subagents.prompt(request({ @@ -220,9 +250,7 @@ describe('subagent gateway', () => { }) it('maps history disappearance and hides unexpected backend details', async () => { - const disappeared = bench({ - readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'), - }) + const disappeared = bench({ storedChild: false }) expect((await disappeared.api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', }))).result).toMatchObject({ From c98a754ccbe3582988a40a4b8dca8c7003c807c7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:51:08 +0800 Subject: [PATCH 167/190] fix: address review round four - the cached-identity rung gains a finality gate: the identity value carries its descriptor seq and a cached row is served only when that seq lands in the child's own suffix, so a fork seed's replayed ancestor identity can never outrank the authoritative refold (stateVersion bumped for the state-shape change) - the cold preparation validates the inspected header against the enumerated candidate's lifecycle witness; a republished id degrades to that child's corrupt diagnostic instead of leaking the new owner's log - the new projection registration proves HMR disposal; companion notes qualify the superseded decision text and record the deliberate error-face asymmetry --- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 22 +++--- ...06-subagent-list-identity-projection.zh.md | 22 +++--- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 2 +- ...ble-subagent-catalog-and-list-agents.zh.md | 2 +- docs/cordis-catalog/services.md | 5 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/index.ts | 5 +- .../subagent/subagent/src/list-children.ts | 53 +++++++++---- .../subagent/subagent/src/projection-types.ts | 9 +++ packages/subagent/subagent/src/projection.ts | 14 +++- .../subagent/tests/list-children.spec.ts | 78 ++++++++++++++++++- .../subagent/tests/timing-projection.spec.ts | 12 ++- 20 files changed, 188 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index cb76aaf7b5..1f34f2c1ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 0684ec284fbce161e37a9f7c7c57460d91c8d0d0 -2026-08-06-subagent-list-identity-projection.zh.md: ea5fb4329032e42bf394706032e8571d5a975e2e +2026-08-06-subagent-list-identity-projection.md: 535737f183c4e3a1f12e9e2c9c55c5a25c9e2006 +2026-08-06-subagent-list-identity-projection.zh.md: dccf0c781ddde882a5d1941fac470907f9422b44 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index 0684ec284f..535737f183 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -14,14 +14,14 @@ The root cause is that the [durable-subagent-catalog decision](../feature/2026-0 ## Decision -mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served value is final; otherwise it pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache of its own, no write-back. +mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache of its own, no write-back. There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was once this note's settled direction and was under construction for a time, then retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered. Key points: - **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual. -- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when a non-null `subagent` identity is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index. +- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when a non-null `subagent` identity passing the seq gate (`seq >= seedLength ?? 0`) is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index. - **The `subagent` projection unit is the sole authority over the fold rules**: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists. - **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration. @@ -36,8 +36,8 @@ It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packa ```ts ignore-check export type SubagentIdentityProjection = - | { mode: 'one-shot'; label?: string } - | { mode: 'continuable'; label: string } + | { mode: 'one-shot'; label?: string; seq: number } + | { mode: 'continuable'; label: string; seq: number } declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { @@ -47,7 +47,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' { ``` - The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a **serializable null sentinel**: the map entry is `SubagentIdentityProjection | null`, non-optional, never undefined or an absent key. The reason: the registry's onChanged push goes through JSON serialization, where an undefined field is dropped by stringify, the client's frame validation rejects the frame, and a consumer's stored old identity would never update; null passes frames intact, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). -- Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; this discriminant matches the child row's strong mode/label contract below exactly. +- Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no `seq` — it is the projection's internal own-suffix proof). +- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The state gaining `seq` bumps the unit's `stateVersion` to 2, and existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold. - Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. ### Enumeration: subagent-owned live-preferred merge @@ -68,19 +69,20 @@ For each enumerated child, mode/label retrieval walks a three-rung ladder — co | Rung | Read | Cost | | --- | --- | --- | | 1: live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | -| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly when a non-null `subagent` identity is among its values — identity is immutable once appended, so a served value is final regardless of the row's watermark | Zero log reads | +| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly only when a non-null `subagent` identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads | | 3: cold child, fallback | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing | -- Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. The session store gets the same posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. -- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. +- Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. The session store gets the same posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. The two codes map differently on the wire: apiproxy gives only `PROJECTIONS_UNAVAILABLE` a dedicated wire face, and `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — the apiproxy composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. +- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict. - Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping). +- The cold path's lifecycle witness: preparation's result must still point at the lifecycle that was enumerated — the witness field set is the same seven fields as the old SOURCE_CONFLICT check (version, id, createdAt, cwd, parentSession, seedLength, delegationDepth); a session deleted and republished under the same id degrades to a `corrupt` row in the old parent's catalog, leaking nothing of the new owner's child. - Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field. - The cold-read cost, recorded honestly: only with the cache unmounted or missed does a cold child pay one full read per listing, at a cost proportional to its transcript size; the settled stance is compute-and-discard, and no cache of its own is built. The full read goes through `inspect()` into the [Session preparation](2026-08-05-session-preparation.md) cold read, so short-term repeated reads of the same id can hit its LRU for reuse, but listing does not depend on this. A live child reads zero log throughout. - Cancellation: the caller's signal is checked before and after each persistence read, and a read that settles only after abort is rejected, normalized to the stable error code `CANCELLED`. ### Authority model -- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints of its own, no in-process memo; the `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which this design only reads and never writes. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read (identity is immutable, so a cached value has no staleness problem). +- The session log is the sole authority; this design adds no derived persistence of any kind — no index values, no checkpoints of its own, no in-process memo; the `sessionProjectionCache` checkpoint the second rung reads is an existing composition item's derived data, which this design only reads and never writes. Values are computed on read and discarded, and a value's freshness is exactly the live state or persisted revision at the moment of the read (an own descriptor is immutable once appended — a cached identity past the seq gate has no staleness problem; the gate guards against seed-replayed ancestor identities). - The Session and persistence write paths are entirely unaware of listing and projection consumption: no event-listener write-back, no fold-on-write. - Enumeration and value retrieval constitute no second authorization source and make no unpublished child visible — the two sources see only published live records and durably written persisted records, consistent with the rule the durable-subagent-catalog note laid down for derived read surfaces. @@ -162,7 +164,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Verification -`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Four second-rung cases: a real-composition cache hit with zero `inspect`, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold. The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row. +`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Second-rung cases: an own-seq identity used directly with zero `inspect`, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to `corrupt` field by witness field (`it.each` over the seven). The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index ea5fb43290..dccf0c781d 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -14,14 +14,14 @@ Status: implemented ## 决策 -mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。 +mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级"算完即止"阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。 消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。"值随查询索引落库"曾是本记录的定稿方向并一度施工,最终整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被"算完即止"显式接受。前两条与退役理由详见考虑过的替代方案一节。 要点: - **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 -- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。 +- **取值三级"算完即止"阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 且过 seq 门(`seq >= seedLength ?? 0`)的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。 - **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 - **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 @@ -36,8 +36,8 @@ mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠 ```ts ignore-check export type SubagentIdentityProjection = - | { mode: 'one-shot'; label?: string } - | { mode: 'continuable'; label: string } + | { mode: 'one-shot'; label?: string; seq: number } + | { mode: 'continuable'; label: string; seq: number } declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { @@ -47,7 +47,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' { ``` - 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:registry 的 onChanged 推送经 JSON 序列化,undefined 字段被 stringify 丢弃,客户端帧校验拒收,消费方存储的旧身份将永不更新;null 完好过帧,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。"算出来没有"如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 -- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;该判别式与下文 child 行的 mode/label 强契约完全一致。 +- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强契约完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。 +- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。state 增 `seq` 使 unit `stateVersion` 升至 2,既存 checkpoint 行按 registry 契约版本失配失效、落权威重折。 - 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 ### 枚举:subagent 自管 live-preferred 合并 @@ -68,19 +69,20 @@ declare module '@deepseek-ai/dsh-session-projection/types' { | 级 | 读法 | 成本 | | --- | --- | --- | | 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | -| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份即直接用——身份一经追加不可变,读到即定值,无视行水位 | 零日志读 | +| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份且 `identity.seq >= header.seedLength ?? 0` 才直接用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 | | 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 | -- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 -- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。 +- 错误契约:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。两码的 wire 映射有别:apiproxy 只为 `PROJECTIONS_UNAVAILABLE` 设专门 wire 脸,`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 +- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮契约相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。 - per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。 +- 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。 - 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。 - 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案"算完即止",不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 - 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。 ### 权威模型 -- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(身份不可变,缓存值无陈旧性问题)。 +- session log 是唯一权威;本方案不新增任何派生持久化——没有索引值、没有自己的 checkpoint、没有进程 memo;第二级读取的 `sessionProjectionCache` checkpoint 是既有组合项的派生数据,本方案只读不写。取值现算现弃,值的新鲜度就是读取时点的 live 状态或持久化 revision(own descriptor 一经追加不可变——缓存身份过 seq 门后无陈旧性问题,门防的是种子回放的祖先身份)。 - Session 与 persistence 写路完全不感知列表与投影消费:没有事件监听回写,没有写时折叠。 - 枚举与取值不构成第二个鉴权来源,也不让尚未发布的 child 可见——两个来源只见已发布的 live 记录与已落盘的持久化记录,与 durable-subagent-catalog 记录对派生读面立下的规则一致。 @@ -162,7 +164,7 @@ export type SubagentListEntry = ## 验证 -`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级四例:真组合 cache 命中零 `inspect`、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。 +`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本契约:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;provider 未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index a4037b2043..2acc3dd5b5 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: 9515cf744706765dc2a5f34311198d2432d22924 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 25b019713c143c268cb10bd3a3dd0d774f97ad8f +2026-07-22-durable-subagent-catalog-and-list-agents.md: b96d6e1dd36c58af67c8e93e62515672790ad009 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 856bac615db84bfe2898ec0838094c6bc29f77b2 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 9515cf7447..b96d6e1dd3 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -35,7 +35,7 @@ Session lineage is broader than subagent identity: an ordinary `ctx.sessions.for The published logical record is also the activity source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `inactive`. Activity comes directly from the trace and causes no additional child-log load. `inactive` encodes neither successful completion nor resumability: it may describe settled one-shot history or a continuable child for which `send_message` can materialize another Activation. Conversely, `running` says only that the session is live: a live continuable Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects it as an ownership conflict. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or activity source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation. -The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentService`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. +The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentService`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. This dependency posture — the optional `sessionQuery`, its error code, and the list tool's load requirement — is part of the superseded read path: the current codes (`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`) and the narrowed load requirement live in the [superseding note](../architecture/2026-08-06-subagent-list-identity-projection.md). `listChildren(parentSessionId, signal?)` forwards the caller's signal to `traceSession()` and the conditional exact `readEvent()` operation. `listEvents()` has no cancellation parameter, so the listing path checks the signal before and after that await and after each candidate settles. If any query operation rejects after the signal aborts, the service normalizes the result to `SubagentError` with stable code `CANCELLED`; a backend abort error or a diagnostic-mapped query error cannot escape or become a successful partial listing. diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index 25b019713c..856bac615d 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -35,7 +35,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su 已发布的逻辑记录同时也是活动状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `inactive`。活动状态直接来自追踪结果,不会导致额外加载 child 日志。`inactive` 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 `send_message` 可以为其物化另一次 Activation 的可继续 child。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 `running`,但 `send_message` 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。 -subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时契约。 +subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时契约。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行错误码(`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`、`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)与收窄后的加载要求以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.md)为准。 `listChildren(parentSessionId, signal?)` 会把调用方的取消信号转发给 `traceSession()` 和条件性精确 `readEvent()` 操作。`listEvents()` 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 `SubagentError`,并携带稳定错误码 `CANCELLED`;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 411aed5fad..2ded3e4f07 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2105,8 +2105,9 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise<void> * serves each child's durable mode/label from the registered `subagent` * projection unit down a three-rung ladder — the registry's watermark * snapshot for a live child; for a cold one, a durable projection-cache - * row when the optional cache already serves the identity (the value is - * immutable, so staleness cannot matter), else one persistence inspection + * row when the optional cache serves an own-suffix identity (its `seq` + * gate proves the value postdates the fork seed, where a child's own + * descriptor is immutable once appended), else one persistence inspection * folded through the registry. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 644634044a..eb708f33da 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: 514963706dedbf88c519c8c83aaf2bd6954fe492 -subagent.zh.md: 9835e8881841a0fbe682a2645731344265bfa762 +subagent.md: 4d7552eb5749284d90e31e62d8ac02e3d7a21b1d +subagent.zh.md: ebcb1ce445c164352109d6613028c7a36c10d6d4 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 514963706d..4d7552eb57 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -252,7 +252,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn ## Durable enumeration: `listChildren()` and `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — a served identity is final, because identity is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). +`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). ## The terminal result: `SubagentResult` diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 9835e88818..ebcb1ce445 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -252,7 +252,7 @@ interface ContinuableCreateSpec { ## 持久化枚举:`listChildren()` 与 `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——身份一经追加不可变,读到即定值);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 +`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 ## 终态结果:`SubagentResult` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 86b63052da..63c1a491e1 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -938,7 +938,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>', - jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache already serves the identity (the value is\n * immutable, so staleness cannot matter), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */', + jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache serves an own-suffix identity (its `seq`\n * gate proves the value postdates the fork seed, where a child\'s own\n * descriptor is immutable once appended), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */', }, { signature: 'registerProvider(provider: SubagentProvider): () => void', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 1de57e2fae..f5ceedc743 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: 92c8222381338f71c7d444da80c23098aba247b3 -README.zh.md: 8f61379bb789de6c1b97b22db034698009d0619c +README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829 +README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 92c8222381..9d2e38c873 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -84,7 +84,7 @@ When `ctx.sessionProjections` is available, the service registers two projection ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache already serves the identity (immutable once appended, so staleness cannot matter), else one bounded-concurrency persistence inspection folded through the registry. A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 8f61379bb7..341c18617a 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -84,7 +84,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行——身份一经追加即不可变,故无须关心行的新旧——命中即用,否则经一次有界并发的持久化 inspect 再经注册表折叠。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 216ad4eca4..5bcd53d6a5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -290,8 +290,9 @@ export class SubagentService extends Service { * serves each child's durable mode/label from the registered `subagent` * projection unit down a three-rung ladder — the registry's watermark * snapshot for a live child; for a cold one, a durable projection-cache - * row when the optional cache already serves the identity (the value is - * immutable, so staleness cannot matter), else one persistence inspection + * row when the optional cache serves an own-suffix identity (its `seq` + * gate proves the value postdates the fork seed, where a child's own + * descriptor is immutable once appended), else one persistence inspection * folded through the registry. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 097c3b1019..f095be695f 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -216,12 +216,13 @@ export async function listChildren( /** * Resolve one cold candidate down the remaining ladder: a durable - * projection-cache row when it already serves the identity, otherwise one - * persistence inspection folded through the projection registry (the same - * detached recipe the API proxy uses for detached session projections). A - * failed inspection is one transient `unavailable` row retried on the next - * listing; a settled log the fold cannot identify — or that makes any - * registered unit throw — is final, so it reports `corrupt`. + * projection-cache row when it serves an own-suffix identity (the seq gate), + * otherwise one persistence inspection folded through the projection + * registry (the same detached recipe the API proxy uses for detached session + * projections). A failed inspection is one transient `unavailable` row + * retried on the next listing; an inspection naming another lifecycle, and a + * settled log the fold cannot identify — or that makes any registered unit + * throw — are final, so they report `corrupt`. */ async function resolveColdIdentity( persistence: SessionPersistence, @@ -242,19 +243,22 @@ async function resolveColdIdentity( // row of ANY unit) silently falls through to the authoritative re-fold. cached = undefined } - // A served identity is immutable once appended, so a cached one is final - // regardless of the row's watermark. Both no-value forms fall through to - // preparation: an absent key (a checkpoint cut before the descriptor was - // appended) and the `null` sentinel, whose verdict belongs to the - // authoritative re-fold, not to a derived row. - if (cached !== undefined && cached !== null) { + // A child's OWN descriptor is immutable once appended, so a cached + // identity is final only when the seq gate proves it was folded from the + // own suffix: a creation-window checkpoint may instead carry a fork + // seed's replayed ANCESTOR descriptor (seq below `seedLength`), which + // must not outrank the re-fold. Everything else also falls through to + // preparation: an absent key (a cut before any descriptor) and the + // `null` sentinel, whose verdict belongs to the authoritative re-fold, + // not to a derived row. + if (cached !== undefined && cached !== null && cached.seq >= (header.seedLength ?? 0)) { return childRow(childId, cached, 'inactive', hasChildren) } } assertListingNotCancelled(signal) - let events: readonly SessionEvent[] + let inspected: { meta: SessionHeader; events: readonly SessionEvent[] } try { - events = (await persistence.inspect(childId, signal)).events + inspected = await persistence.inspect(childId, signal) } catch { // Per-child isolation: the child vanished or its backend read failed — // one diagnostic row, and the listing itself still succeeds. @@ -262,9 +266,15 @@ async function resolveColdIdentity( return { kind: 'diagnostic', id: childId, reason: 'unavailable' } } assertListingNotCancelled(signal) + // A session id names a slot, not a lifecycle: a child deleted and + // re-published under another owner between the enumeration and this read + // must not leak into the old parent's listing. + if (!sameLifecycle(inspected.meta, header)) { + return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + } let identity: SubagentIdentityProjection | null | undefined try { - identity = projections.restore({}, events, 0).snapshot.values.subagent + identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent } catch { // The restore folds EVERY registered unit over this child's log, so any // unit's fold or schema can reject damaged payloads — deterministic data @@ -303,6 +313,19 @@ function childRow( } } +/** Immutable header fields that distinguish one session lifecycle from another under the same id. */ +const LIFECYCLE_WITNESS_KEYS = [ + 'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth', +] as const + +/** + * Whether an inspected log still belongs to the enumerated lifecycle, + * mirroring the retired query-source compatibility check's field set. + */ +function sameLifecycle(meta: SessionHeader, expected: SessionHeader): boolean { + return LIFECYCLE_WITNESS_KEYS.every(key => meta[key] === expected[key]) +} + /** Stop a listing at its next cancellation checkpoint. */ function assertListingNotCancelled(signal: AbortSignal | undefined): void { if (signal?.aborted) { diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index 2d40126427..046c32e91d 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -29,12 +29,21 @@ export type SubagentIdentityProjection = mode: 'one-shot' /** Optional durable creation label from the child's descriptor. */ label?: string + /** + * Seq of the `subagent/descriptor` event this identity was folded from. + * `seq >= header.seedLength` proves the identity comes from the child's + * OWN log suffix — where a descriptor is immutable once appended — and + * not from a fork seed's replayed ancestor descriptor. + */ + seq: number } | { /** A resumable conversation. */ mode: 'continuable' /** Durable creation label from the child's descriptor. */ label: string + /** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */ + seq: number } declare module '@deepseek-ai/dsh-session-projection/types' { diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index f57af14ffa..9473f3b003 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -99,10 +99,12 @@ const identitySchema = z.discriminatedUnion('mode', [ z.object({ mode: z.literal('one-shot'), label: z.string().optional(), + seq: z.number().int().nonnegative(), }).strict(), z.object({ mode: z.literal('continuable'), label: z.string(), + seq: z.number().int().nonnegative(), }).strict(), ]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null> @@ -118,8 +120,12 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u } if (descriptor === undefined) return undefined return descriptor.mode === 'one-shot' - ? { mode: 'one-shot', ...descriptor.label !== undefined ? { label: descriptor.label } : {} } - : { mode: 'continuable', label: descriptor.label } + ? { + mode: 'one-shot', + ...descriptor.label !== undefined ? { label: descriptor.label } : {}, + seq: event.seq, + } + : { mode: 'continuable', label: descriptor.label, seq: event.seq } } /** @@ -144,5 +150,7 @@ ProjectionDefinition<'subagent', IdentityState> = { return identity === undefined ? {} : { identity } }, view: state => state.identity ?? null, - stateVersion: 1, + // Bumped when the identity gained its `seq` field: an older checkpoint row + // would replay into a value the schema rejects, so it must refold instead. + stateVersion: 2, } diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 2dba308c7f..118bba06f9 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -373,7 +373,7 @@ describe('SubagentService.listChildren', () => { live.append('turn/start', { turn: 1 }) live.append('subagent/descriptor', descriptorPayload('was valid')) expect(ctx.sessionProjections.snapshot(live).values.subagent) - .toEqual({ mode: 'continuable', label: 'was valid' }) + .toEqual({ mode: 'continuable', label: 'was valid', seq: 1 }) // Last-wins: the malformed follow-up resets the identity to the sentinel. live.append( 'subagent/descriptor', @@ -409,6 +409,82 @@ describe('SubagentService.listChildren', () => { ]) }) + it('serves a cached own-suffix identity directly without inspection', async () => { + const { ctx, parent } = await setup([], { projectionCache: true }) + const child = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae01', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('disk label'))) + // seq 2 >= seedLength 0: the cached identity provably comes from the + // child's own suffix, so it is final and the log is never re-read — the + // divergent label proves the row, not the log, produced the entry. + ctx.sessionProjectionCache.cachedSnapshot = () => ({ + asOfSeq: 2, + values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } }, + }) + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ + kind: 'child', id: child, label: 'cached own', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }]) + expect(inspect).not.toHaveBeenCalled() + }) + + it('refuses a cached ancestor identity from the fork seed and lets preparation rule', async () => { + const { ctx, parent } = await setup([], { projectionCache: true }) + // A fork child: the seed replays the ancestor's descriptor (seq 2), and + // the child's own descriptor arrives in its first own turn (seq 5). + const seed = childEvents(descriptorPayload('ancestor label')) + const events = [ + ...seed, + { type: 'turn/start', seq: 4, time: 5, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'subagent/descriptor', seq: 5, time: 6, data: descriptorPayload('own label') }, + { type: 'turn/end', seq: 6, time: 7, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae02', { + parentSession: parent.id, + seedLength: seed.length, + origin: 'subagent', + }, events) + // A creation-window checkpoint carried the ANCESTOR identity: its seq 2 + // fails the own-suffix gate (< seedLength 4), so preparation rules. + ctx.sessionProjectionCache.cachedSnapshot = () => ({ + asOfSeq: 2, + values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } }, + }) + const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ + kind: 'child', id: forkChild, label: 'own label', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }]) + expect(inspect).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })], + ['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })], + ] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => { + const { ctx, parent } = await setup([textResponse('done')]) + const healthy = await startChild(ctx, parent, 'healthy sibling') + const reborn = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae03', { + parentSession: parent.id, + origin: 'subagent', + }, childEvents(descriptorPayload('reborn child'))) + const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence) + ctx.sessionPersistence.inspect = async (sessionId, signal) => { + const result = await original(sessionId, signal) + if (sessionId !== reborn) return result + // The id was re-published as a different lifecycle after enumeration. + return { ...result, meta: mutate(result.meta) } + } + const entries = await ctx.subagents.listChildren(parent.id) + expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' }) + expect(entries).toContainEqual({ + kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', + activity: 'inactive', hasChildren: false, + }) + }) + it('lets preparation rule when the cache serves the null sentinel', async () => { const { ctx, parent } = await setup([], { projectionCache: true }) const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', { diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index e9a3be43ea..0165f73714 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -23,11 +23,15 @@ describe('subagent timing projection', () => { await ctx.plugin(SessionProjectionRegistry) const serviceFiber = await ctx.plugin(SubagentService) - expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) - .toEqual({ settledMs: 0 }) + const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values + expect(before.subagentTiming).toEqual({ settledMs: 0 }) + // The identity unit registers alongside timing; an empty log serves its + // serializable null sentinel. + expect(before.subagent).toBeNull() await serviceFiber.dispose() - expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) - .toBeUndefined() + const after = ctx.sessionProjections.snapshot(ctx.sessions.create()).values + expect(after.subagentTiming).toBeUndefined() + expect(after.subagent).toBeUndefined() }) it('resets inherited seed timing at the child descriptor and sums later completed turns', () => { From 3216150ae011902732aeb948d1d7e8f4718f6a1d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:37:25 +0800 Subject: [PATCH 168/190] fix: address review round next - subagents.history computes its projections best-effort on both arms (a hostile unit's fold rejection serves the page without the block, matching the session-list precedent) with dual-arm coverage - the lifecycle-witness spec probes every field of the seven-key witness, protecting the key list itself - list-children's own module docs catch up with the seq-gate contract, and the design note records the rung-two later-event window as an accepted, self-healing deviation of the corruption class --- ...ubagent-list-identity-projection.i18n.yaml | 4 +-- ...08-06-subagent-list-identity-projection.md | 3 +- ...06-subagent-list-identity-projection.zh.md | 3 +- packages/host/apiproxy/src/api-proxy.ts | 28 +++++++++++++-- .../tests/api-proxy-subagents.spec.ts | 35 +++++++++++++++++-- .../subagent/subagent/src/list-children.ts | 9 ++--- .../subagent/tests/list-children.spec.ts | 5 +++ 7 files changed, 75 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 1f34f2c1ea..0168528557 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 535737f183c4e3a1f12e9e2c9c55c5a25c9e2006 -2026-08-06-subagent-list-identity-projection.zh.md: dccf0c781ddde882a5d1941fac470907f9422b44 +2026-08-06-subagent-list-identity-projection.md: ba023d04805ff3335c8f243510aa8ddc15fe13d6 +2026-08-06-subagent-list-identity-projection.zh.md: 368a70f5b3e5a27e4e1c648e8819476d40a57709 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index 535737f183..ba023d0480 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -128,6 +128,7 @@ Known boundary deviations (deliberately accepted, recorded with this note): - A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. - A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). - An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. +- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine. Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). @@ -172,7 +173,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir - The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`. - Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the registry's and the cache's existing reads (snapshot, cachedSnapshot, restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. - Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. -- The diagnostic and enumeration semantics leaves five boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, and an unknown parent yielding an empty list instead of not-found); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data with resume authorization unaffected, and the unknown-parent one is a silent query-semantics change, explicitly accepted. +- The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted. - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. ## Related diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index dccf0c781d..368a70f5b3 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -128,6 +128,7 @@ export type SubagentListEntry = - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 - 未知 parent,旧实现经 session-query 抛 not-found('parent session … was not found');现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 +- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反 establishing provider"恰追加一次"契约,属损坏类数据,与多描述符偏差同族同源;②需"损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)"双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按"cache 永不为权威"总纲归档为接受项。 消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 @@ -172,7 +173,7 @@ export type SubagentListEntry = - subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。 - 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 - per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 -- 诊断与枚举语义留下五处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,恢复鉴权不受影响,未知 parent 一处是查询语义的静默变化,显式接受。 +- 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 ## 相关 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d9ed8115f2..19fb0fe8a2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -527,6 +527,28 @@ function detachedProjectionsFor( return registry.restore({}, events, 0).snapshot } +/** + * Best-effort projections for one subagent history page, fail-soft like + * {@link listProjectionsFor}: a registered unit throwing on a corrupt payload + * never blocks transcript reading — the page is served without the block. + * @param ctx - context carrying the logger for the degradation warning. + * @param childSessionId - the child whose page is being decorated. + * @param compute - the arm-specific fold (live watermark or detached restore). + * @returns the projections block, or undefined when the fold failed. + */ +function subagentHistoryProjections( + ctx: Context, + childSessionId: SessionId, + compute: () => SessionProjectionsBlock | undefined, +): SessionProjectionsBlock | undefined { + try { + return compute() + } catch (error) { + ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`) + return undefined + } +} + /** Map continuation admission failures without exposing provider details. */ function subagentPromptError( request: RpcRequest<{ childSessionId: SessionId }>, @@ -1928,14 +1950,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (attached !== undefined) { header = attached.header events = [...attached.events] - projections = beforeSeq === undefined ? projectionsFor(ctx, attached) : undefined + projections = beforeSeq === undefined + ? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached)) + : undefined } else { try { const inspected = await inspectServable(childSessionId) header = inspected.meta events = inspected.events projections = beforeSeq === undefined - ? detachedProjectionsFor(ctx, inspected.events) + ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events)) : undefined } catch (error: unknown) { if (signal?.aborted) { diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index 691b97b7b4..c761484da5 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -24,6 +24,8 @@ function bench(options: { storedChild?: false /** Attach the child to the live session store instead of persistence only. */ liveChild?: true + /** Every registered projection unit throws on this child's payloads. */ + projectionsThrow?: true historyParent?: SessionId } = {}) { const parent = { id: PARENT } @@ -60,8 +62,14 @@ function bench(options: { const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents })) const liveBlock = { values: {}, asOfSeq: 3 } const coldBlock = { values: {}, asOfSeq: 0 } - const snapshot = vi.fn(() => liveBlock) - const restore = vi.fn(() => ({ snapshot: coldBlock })) + const snapshot = vi.fn(() => { + if (options.projectionsThrow === true) throw new Error('hostile unit') + return liveBlock + }) + const restore = vi.fn(() => { + if (options.projectionsThrow === true) throw new Error('hostile unit') + return { snapshot: coldBlock } + }) const ctx = new Context() ctx.provide('agents', { get: getAgent }) ctx.provide('subagents', { listChildren, followup }) @@ -155,6 +163,29 @@ describe('subagent gateway', () => { expect(inspect).not.toHaveBeenCalled() }) + it('serves the page without projections when a hostile unit breaks the fold', async () => { + const cold = bench({ projectionsThrow: true }) + const coldResponse = await cold.api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + })) + expect(coldResponse.result).toMatchObject({ + ok: true, + value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, + }) + if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false) + + const live = bench({ projectionsThrow: true, liveChild: true }) + const liveResponse = await live.api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + })) + expect(liveResponse.result).toMatchObject({ + ok: true, + value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, + }) + if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false) + expect(live.snapshot).toHaveBeenCalledTimes(1) + }) + it('reads one-shot history and rejects an address with the wrong mode', async () => { const oneShot = { kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index f095be695f..fef098f90f 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -5,9 +5,10 @@ * durable `origin: 'subagent'` under the parent; each child's mode/label is * the registered `subagent` projection unit's value, resolved down a * three-rung ladder: the registry's watermark cache for a live child, a - * durable projection-cache row when the optional cache already serves the - * identity, and one persistence inspection folded through the registry - * otherwise. The projection fold is the single + * durable projection-cache row when it serves an own-suffix identity (the + * seq gate), and one persistence inspection folded through the registry + * otherwise, validated against the enumerated lifecycle. The projection + * fold is the single * classification authority — this module parses no descriptor itself. Absent * persistence, enumeration is live-only: a cold child is unreachable for * resume anyway, so its absence is capability absence, not an error. The @@ -92,7 +93,7 @@ export type SubagentListEntry = * live-preferred merge of `ctx.sessions` and optional session persistence, * serving each identity from the `subagent` projection unit: the registry's * watermark snapshot for a live child; for a cold one, a durable - * projection-cache row when the optional cache already serves the identity, + * projection-cache row when it serves an own-suffix identity (the seq gate), * else one bounded-concurrency persistence inspection folded through the * registry. * @see SubagentService.listChildren for the public cancellation and failure contract. diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 118bba06f9..ef52883db2 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -461,7 +461,12 @@ describe('SubagentService.listChildren', () => { }) it.each([ + ['version', (meta: SessionHeader): SessionHeader => ({ ...meta, version: meta.version + 1 })], + ['id', (meta: SessionHeader): SessionHeader => ({ ...meta, id: SessionId('another-lifecycle') })], ['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })], + ['cwd', (meta: SessionHeader): SessionHeader => ({ ...meta, cwd: '/elsewhere' })], + ['parentSession', (meta: SessionHeader): SessionHeader => ({ ...meta, parentSession: SessionId('another-parent') })], + ['seedLength', (meta: SessionHeader): SessionHeader => ({ ...meta, seedLength: (meta.seedLength ?? 0) + 1 })], ['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })], ] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => { const { ctx, parent } = await setup([textResponse('done')]) From 1019f149c4e4ad2b75d7c9a15e0976ceaa05e7c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 11:02:38 +0800 Subject: [PATCH 169/190] =?UTF-8?q?fix(web,llm):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20document=20the=20card=20contract,=20pin=20the=20hos?= =?UTF-8?q?t=20diagnosis,=20gate=20the=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cordis-catalog/services.md | 2 +- .../headless-agent/tests/headless.snapshot.ts | 40 +++++++++++++++++++ .../stream-json.expected.jsonl | 12 ++++++ packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 8 +++- .../ui-models/src/client/ModelListEditor.tsx | 13 +++++- .../ui-models/src/client/ProviderEditor.tsx | 2 +- .../client/ui-models/src/client/locales.ts | 2 + .../ui-models/tests/provider-form.spec.tsx | 13 ++++++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 6 ++- packages/llm/llm/src/index.ts | 11 +++-- packages/llm/llm/tests/api-key.spec.ts | 2 +- 14 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6b30d80751..d71619b7d9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -941,7 +941,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk> Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:287`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index eb39254dac..9de860d2b5 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -31,6 +31,10 @@ const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) +// Same keyless composition as the missing-credential scenario: the endpoint is +// never dialed either way, because a supplied-but-unusable key fails credential +// resolution exactly where an absent one does. +const invalidCredentialScenarioDir = join(snapshotsDir, 'invalid-credential') const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) @@ -254,6 +258,42 @@ describe('headless stream-json snapshots', () => { expect(normalized).toContain('as a last resort') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs actionable invalid-credential guidance through the one-shot app', async () => { + const streamExpected = join(invalidCredentialScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'invalid-credential headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-invalid-credential-', + binScript, + configPath: credentialsConfigPath, + binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + tsconfigPath, + env: { + // A key that exists but no HTTP header can carry — the paste this + // change exists for. Before it, `fetch` refused to build the header + // and the turn ended on a retried ByteString TypeError. + DEEPSEEK_API_KEY: 'sk-\u{1F600}pasted-from-a-chat-window', + DEEPSEEK_BASE_URL: '', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + // The durable failure names the reference to correct and the writer that + // usually owns it, and stays true in a composition that mounts no Models + // page at all. + expect(normalized).toContain('the API key resolved from DEEPSEEK_API_KEY contains characters') + expect(normalized).toContain('the web Models page writes it') + // Neither the key nor the transport-level symptom it used to produce may + // reach the user: the code point of one character is still the key. + expect(normalized).not.toContain('pasted-from-a-chat-window') + expect(normalized).not.toContain('ByteString') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs the model default and a dynamic next-step reasoning effort', async () => { const result = await runLoaderSmoke({ label: 'reasoning effort headless stream-json snapshot', diff --git a/examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl new file mode 100644 index 0000000000..f521487e42 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl @@ -0,0 +1,12 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"say pong","messageSeqs":[4],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}} +{"type":"result","sessionId":"{{sessionId}}","output":""} diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ae296a91aa..3db62f6c31 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: b55914197e472edec8a8b6d4d3e02036d1697728 -README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec +README.md: e3328bb5fd2cf812b05dc26bf534226818132631 +README.zh.md: 20e40cc571a9123b50dfb28565c5562937e03189 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index b55914197e..e3328bb5fd 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes fails too; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index ca93c3d5a2..20e40cc571 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值也会失败——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index a252d99586..a610f2f140 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -215,7 +215,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { disabled={disabled} onChange={(event) => { setKeyDraft(event.target.value) }} /> - {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>} + {/* A create card has no stored key to keep, so the blank case says + what a blank field means here instead: this route may authenticate + through the provider's own ambient discovery or OAuth. */} + {keyFailure === undefined + ? null + : <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>} </div> <ModelListEditor models={models} @@ -226,6 +231,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { api: protocol, ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} + probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure} api={api} t={t} disabled={disabled} diff --git a/packages/client/ui-models/src/client/ModelListEditor.tsx b/packages/client/ui-models/src/client/ModelListEditor.tsx index e60c8c24ed..b2966568ae 100644 --- a/packages/client/ui-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-models/src/client/ModelListEditor.tsx @@ -74,6 +74,13 @@ export interface ModelListEditorProps { onReset?: () => void /** Endpoint facts for the fetch action. */ probe: ProbeTarget + /** + * Copy key naming why the fetch action is unavailable, or `undefined` when + * it is. The card owns this because the key it would send is judged there: + * asking with a key the form has already refused spends a round trip to be + * told what the field already says. + */ + probeBlocked?: keyof typeof en | undefined /** Wire face the fetch action calls. */ api: Pick<IApiClient, 'llm'> /** Section copy. */ @@ -314,8 +321,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { <button type="button" className={styles['linkButton']} - disabled={disabled || busy || !askable} - title={askable ? undefined : t('fetchNeedsBaseUrl')} + disabled={disabled || busy || !askable || props.probeBlocked !== undefined} + title={props.probeBlocked !== undefined + ? t(props.probeBlocked) + : askable ? undefined : t('fetchNeedsBaseUrl')} onClick={() => { void fetchModels() }} > {busy ? t('fetching') : t('fetchModels')} diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index f33791eab5..79ac122c83 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -371,7 +371,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} /> ) - : <ModelListEditor {...catalogProps} probe={probe} api={api} />} + : <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />} </div> </details> </> diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 0d50c03e63..85f7c14f97 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -47,6 +47,7 @@ export const en = { removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', + keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.', keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', @@ -134,6 +135,7 @@ export const zh: typeof en = { removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', + keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。', keyIllegalCharacters: '该 API 密钥格式错误,请检查。', keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index a167710153..13c1511bb2 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -954,6 +954,19 @@ describe('API key field', () => { expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc') }) + it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => { + const { discover } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // The host would refuse this before building the header anyway; asking is + // a round trip to be told what the field already says. + expect(buttonNamed(en.fetchModels).disabled).toBe(true) + expect(buttonNamed(en.fetchModels).title).toBe(en.keyIllegalCharacters) + expect(discover).not.toHaveBeenCalled() + }) + it('carries the trimmed key into an interrogation, not the padded draft', async () => { const { discover } = await mountSection() openEditor('openai') diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 63b43ecdab..85221e7ca2 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -325,8 +325,10 @@ describe('probe key format', () => { }) it('reports a blank probe key as a credential fault too', async () => { - // A cleared form field arrives as '', not an absent key; it must fail the - // same way a typed-in illegal key does, rather than probing unauthenticated. + // The Models page omits `apiKey` entirely for a cleared field rather than + // sending '', so this pins the contract for every other caller: a supplied + // key is judged, and only an absent one probes unauthenticated. '' means + // "I have a key" and is answered as the empty key it is. await expect(discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions', diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 287bfc2f34..0d1f23af19 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -145,11 +145,16 @@ export class LlmError extends HarnessError { export function assertUsableApiKey(raw: string, pkg: string, ref: string): string { const checked = normalizeApiKey(raw) if (checked.ok) return checked.value + // The Models page is named as the writer it usually is, not as the only one: + // the same value can arrive from a hand-edited .env or a shell export in a + // composition that mounts no credentials seam at all, where directing the + // user to a page that deployment does not serve would be a dead end. throw new LlmError( checked.reason === 'empty' - ? `${pkg}: the API key stored as ${ref} is blank; re-enter it on the web Models page` - : `${pkg}: the API key stored as ${ref} contains characters no HTTP header can carry;` - + ' re-enter it on the web Models page, pasting the raw key only', + ? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key` + + ' (the web Models page writes it) or export it in the launching environment' + : `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;` + + ` set ${ref} to the raw key alone (the web Models page writes it)`, INVALID_CREDENTIAL_CODE, ) } diff --git a/packages/llm/llm/tests/api-key.spec.ts b/packages/llm/llm/tests/api-key.spec.ts index a04a103fb9..783b054b9b 100644 --- a/packages/llm/llm/tests/api-key.spec.ts +++ b/packages/llm/llm/tests/api-key.spec.ts @@ -45,7 +45,7 @@ describe('assertUsableApiKey', () => { it('refuses a blank stored credential, naming the reference', () => { expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY')) - .toThrow(/llm-deepseek: the API key stored as DEEPSEEK_API_KEY is blank/) + .toThrow(/llm-deepseek: the API key resolved from DEEPSEEK_API_KEY is blank/) }) it('refuses an unusable stored credential with the invalid-credential code', () => { From ec1111f18e4b7c3c87773a485133896078033c3b Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 11:04:02 +0800 Subject: [PATCH 170/190] docs: keep the API key Agent Note current with the review fixes --- .../bug-fix/2026-08-06-api-key-format-validation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-06-api-key-format-validation.md | 6 ++++-- .../bug-fix/2026-08-06-api-key-format-validation.zh.md | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 42b42a591a..2ffa228261 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: 9ec247cb2ba2578158759ec1115c5d3a95778cc4 -2026-08-06-api-key-format-validation.zh.md: 63c6a8c17ee93b4b68eb3505d5499756e9fb2401 +2026-08-06-api-key-format-validation.md: a0a99bfcace5422ed021d684c5d5aae48c197af7 +2026-08-06-api-key-format-validation.zh.md: b6dc836cbc7bc828f343d9d376dab6e5c3d424ee diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index 9ec247cb2b..a0a99bfcac 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -60,7 +60,7 @@ The client cannot import any of this: client packages reference only client pack | `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | | `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | | `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | -| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit is gated and the failure renders on the field, matching the existing `modelFailure` pattern. | +| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. | `ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. `CustomProviderCard` carries the same judgement for a hand-declared route. @@ -102,4 +102,6 @@ The costliest way to get this wrong would have been to treat absence as invalidi `packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. -`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. +`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. + +The user-visible terminal state is pinned where it is actually assembled: `examples/headless-agent/tests/headless.snapshot.ts` runs the one-shot app against a stored key no header can carry, over the same keyless composition its missing-credential sibling uses, and records that the turn ends on `INVALID_CREDENTIAL` with an actionable message carrying neither the key nor the word `ByteString`. A package test could not have shown that, and the web e2e covers only the browser half. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 63c6a8c17e..b6dc836cbc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -60,7 +60,7 @@ Status: implemented | `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | | `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | | `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | -| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交受拦截,失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | `ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。 @@ -102,4 +102,6 @@ Status: implemented `packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 -`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 拦截提交、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 +`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 + +用户可见的终态则钉在它真正被组装的位置:`examples/headless-agent/tests/headless.snapshot.ts` 让 one-shot 应用在一个 HTTP 标头无法承载的已存密钥下运行,复用其 missing-credential 兄弟场景的同一套无密钥 composition,并记录该轮以 `INVALID_CREDENTIAL` 结束、消息可操作且既不含密钥也不含 `ByteString` 字样。包级测试无法证明这一点,而 web e2e 只覆盖了浏览器那一半。 From a7d374426803c73d281f8f3fb84882e3458cdde0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 11:16:20 +0800 Subject: [PATCH 171/190] test(web): cover the create card's blank-key copy substitution --- .../ui-models/tests/provider-form.spec.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 13c1511bb2..85125919b5 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -880,6 +880,24 @@ describe('hand-declared providers', () => { expect(set).not.toHaveBeenCalled() }) + it('tells a whitespace-only key what a blank field means on a create card', () => { + const { mutate } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } }) + + // There is no stored key to keep here, so the blank case says the thing + // that is true of a route being declared: it may authenticate elsewhere. + expect(screen.getByText(en.keyBlankNew)).toBeTruthy() + expect(screen.queryByText(en.keyBlank)).toBeNull() + expect(buttonNamed(en.fetchModels).title).toBe(en.keyBlankNew) + expect(buttonNamed(en.create).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + }) + it('creates without a key when the route authenticates some other way', async () => { const { set, onClose } = mountCard() From 5d4cea9dc18ba1f2b8dde518b1e2b710f75b50da Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Fri, 31 Jul 2026 18:10:33 +0800 Subject: [PATCH 172/190] feat(code-runtime): own portable-identifier exclusions at the seam Move the reserved-word, reserved-global, reserved-error-member, and dunder exclusion sets from the worker backend up to the code-runtime seam package, and narrow the portable identifier subset to drop the JS-only `$`. Every backend now imports one contract so a binding namespace list valid on one backend is valid on all. Delivers only the seam extension and the worker's adoption; the Python backend, py-types renderer, and Code Mode language dispatch are later PRs in the stack that depend on these exports. --- ...runtime-portable-identifier-seam.i18n.yaml | 6 ++ ...1-code-runtime-portable-identifier-seam.md | 42 +++++++++++++ ...ode-runtime-portable-identifier-seam.zh.md | 42 +++++++++++++ .../code-runtime-worker/src/index.ts | 44 ++++++++----- .../code-runtime-worker/tests/runtime.spec.ts | 11 ++++ .../code-runtime/code-runtime/src/index.ts | 62 +++++++++++++++++++ .../code-runtime/code-runtime/src/types.ts | 17 ++++- .../code-runtime/tests/reserved.spec.ts | 51 +++++++++++++++ 8 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md create mode 100644 packages/code-runtime/code-runtime/tests/reserved.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml new file mode 100644 index 0000000000..73bf26efee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md +2026-07-31-code-runtime-portable-identifier-seam.md: a24a2c03c937a7d569528504b5bd5fba812009a2 +2026-07-31-code-runtime-portable-identifier-seam.zh.md: 1bef607e31a5820ba849f09eb7ae4cc781c45a84 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md new file mode 100644 index 0000000000..a24a2c03c9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md @@ -0,0 +1,42 @@ +# Agent Note: the code-runtime seam owns portable-identifier exclusions + +Status: implemented + +English | [中文](2026-07-31-code-runtime-portable-identifier-seam.zh.md) + +## Problem + +The code-runtime seam promises that a binding-namespace list valid on one backend is valid on every backend, so a Code Mode consumer can hand the same bindings to any registered runtime without knowing its language. The first backend, `dsh-code-runtime-worker`, privately owned the identifier rules that enforce part of that promise: an `IDENTIFIER` regex that allowed the JS-only `$`, a `RESERVED_WORDS` set holding only ECMAScript keywords, and a `RESERVED_ERROR_PROPERTIES` set of three JS `Error` slots. Those rules described the worker's own language, not the seam's portability contract. + +A second backend written against a different language (CPython, arriving in a later PR of this stack) would either re-declare its own rules — letting `lambda` pass the worker and fail Python, or `$tools` pass the worker and fail every non-JS backend — or import the worker's, inverting the dependency so the seam's implementation reached into a sibling implementation. Neither keeps the portability promise real: it would hold only for the backend a caller happened to test against. + +## Decision + +The seam package (`@deepseek-ai/dsh-code-runtime`) exports the portable-identifier exclusion contract as four named constants, and every backend imports them rather than re-declaring: + +- `PORTABLE_RESERVED_WORDS` — the union of ECMAScript and Python reserved words. A namespace global or error-class name matching any is refused on all backends, so `lambda` is refused even though it is a legal JS parameter name. Adding a language widens this union, which is a deliberate breaking review of existing binding names. +- `RESERVED_BINDING_GLOBALS` — globals some backend owns in the program's namespace: `console` (the worker's log capture) and `__dsh_main__`/`__builtins__`/`__name__`/`__debug__` (the Python bootstrap's wrapper and seeded module globals). Refused everywhere so a namespace list cannot pick a name that works on one backend and collides on another. +- `RESERVED_ERROR_MEMBERS` — error-member names every backend refuses: the JS `Error` slots (`name`, `message`, `stack`) and Python's exception-protocol members (`args`, `with_traceback`, `add_note`). +- `DUNDER_MEMBER` — the dunder-form regex (`__*__`), refused as an error member wholesale because several are constrained CPython descriptors whose exact set is an interpreter-version detail. + +The seam also narrows the portable identifier subset to `[A-Za-z_][A-Za-z0-9_]*` (documented on `CodeBindingNamespace.global` and `CodeBindingErrorClass`), dropping the JS-only `$`. The worker consumes the shared constants: `RESERVED_WORDS = PORTABLE_RESERVED_WORDS`, `RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS`, its `IDENTIFIER` regex loses `$`, and its error-member check adds `DUNDER_MEMBER`. + +The constants live at the seam even though only one backend ships in this PR: the whole point is that the contract is language-agnostic and owned above any single language. A backend that violated it would be the bug, and the shared set is where a reviewer looks to see what "portable" means. + +## Scope + +This PR delivers only the seam extension and the worker's adoption of it. No Python backend, `py-types` renderer, or Code Mode language dispatch ships here — they are later PRs in the stack that depend on these exports. The seam README's worker-only wording is left unchanged for the same reason: linking to a `dsh-code-runtime-python` README that does not yet exist would break the dead-link gate. + +## Alternatives considered + +**Each backend declares its own exclusions.** Rejected: it makes the portability promise per-backend. A binding list the caller tested on the worker could be refused by Python, which is exactly the split the seam exists to prevent. + +**The Python backend imports the worker's constants.** Rejected: it inverts the dependency — the seam's implementations would reach into a sibling implementation for a contract neither owns. The contract belongs above both, at the seam. + +**Keep `$` in the portable identifier subset.** Rejected: `$` is JS-only spelling. Allowing it would let `$tools` pass the worker and fail every non-JS backend, breaking portability for a purely cosmetic gain. + +## Consequences + +Bought: one place — the seam package — defines what a portable binding name is, and every backend enforces the same contract by import. A namespace list valid on one backend is valid on all, verifiably, not by coincidence of which backend the caller tested. + +Cost: existing worker callers using a `$`-containing global now fail identifier validation. Under the pre-release stance this is a corrected foundation, not a compatibility break to shim. The worker's seam-misuse tests gain cases for `$tools`, Python exception members (`args`), dunders (`__dict__`), and a Python-owned global (`__dsh_main__`), proving the shared set is enforced from the worker side. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md new file mode 100644 index 0000000000..1bef607e31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md @@ -0,0 +1,42 @@ +# Agent Note:code-runtime seam 拥有可移植标识符排除集 + +Status: implemented + +[English](2026-07-31-code-runtime-portable-identifier-seam.md) | 中文 + +## Problem + +code-runtime seam 承诺:在一个后端上有效的绑定命名空间列表,在每个后端上都有效,因此 Code Mode 消费方可以把同一组绑定交给任何已注册的运行时,而不必知道它的语言。首个后端 `dsh-code-runtime-worker` 私自拥有了执行这项承诺一部分的标识符规则:一个允许 JS 专有 `$` 的 `IDENTIFIER` 正则、一个只含 ECMAScript 关键字的 `RESERVED_WORDS` 集合,以及一个含三个 JS `Error` 槽位的 `RESERVED_ERROR_PROPERTIES` 集合。这些规则描述的是 worker 自身的语言,而非 seam 的可移植性契约。 + +一个针对不同语言(CPython,将在本 stack 后续 PR 中到来)编写的第二后端,要么重新声明自己的规则——让 `lambda` 通过 worker 却在 Python 上失败,或让 `$tools` 通过 worker 却在每个非 JS 后端上失败——要么导入 worker 的规则,从而反转依赖,使 seam 的一个实现伸手进入另一个兄弟实现。二者都无法让可移植承诺成真:它只对调用方恰好测试过的那个后端成立。 + +## Decision + +seam 包(`@deepseek-ai/dsh-code-runtime`)以四个具名常量导出可移植标识符排除契约,每个后端导入它们而非重新声明: + +- `PORTABLE_RESERVED_WORDS`——ECMAScript 与 Python 保留字的联集。任何命名空间 global 或 error-class 名称匹配其中之一,都在所有后端上被拒绝,因此 `lambda` 即便是合法的 JS 参数名也被拒绝。新增一门语言即扩宽此联集,这是对现有绑定名称的一次有意的破坏性复审。 +- `RESERVED_BINDING_GLOBALS`——某个后端在程序命名空间中拥有的 global:`console`(worker 的日志捕获)与 `__dsh_main__`/`__builtins__`/`__name__`/`__debug__`(Python bootstrap 的包装器与预置模块 global)。在所有后端上被拒绝,使命名空间列表无法选到一个在某后端能用、在另一后端冲突的名称。 +- `RESERVED_ERROR_MEMBERS`——每个后端都拒绝的 error-member 名称:JS `Error` 槽位(`name`、`message`、`stack`)与 Python 异常协议成员(`args`、`with_traceback`、`add_note`)。 +- `DUNDER_MEMBER`——dunder 形式正则(`__*__`),作为 error member 被整体拒绝,因为其中若干是受约束的 CPython 描述符,其确切集合是解释器版本细节。 + +seam 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0-9_]*`(记录在 `CodeBindingNamespace.global` 与 `CodeBindingErrorClass` 上),去掉 JS 专有的 `$`。worker 消费这些共享常量:`RESERVED_WORDS = PORTABLE_RESERVED_WORDS`、`RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS`,其 `IDENTIFIER` 正则去掉 `$`,其 error-member 检查加上 `DUNDER_MEMBER`。 + +尽管本 PR 只交付一个后端,这些常量仍置于 seam:要点正是该契约与语言无关,且拥有权在任何单一语言之上。违反它的后端才是 bug,而共享集合正是复审者查看"可移植"含义的地方。 + +## Scope + +本 PR 只交付 seam 扩展与 worker 对它的采用。这里不交付任何 Python 后端、`py-types` 渲染器或 Code Mode 的语言分发——它们是本 stack 中依赖这些导出的后续 PR。seam README 中仅描述 worker 的措辞保持不变,理由相同:链接到一个尚不存在的 `dsh-code-runtime-python` README 会破坏死链 gate。 + +## Alternatives considered + +**每个后端声明自己的排除集。** 拒绝:这让可移植承诺变成逐后端成立。调用方在 worker 上测过的绑定列表可能被 Python 拒绝,而这正是 seam 存在要防止的分裂。 + +**Python 后端导入 worker 的常量。** 拒绝:这反转依赖——seam 的实现会为一个二者都不拥有的契约伸手进入兄弟实现。契约属于二者之上,即 seam。 + +**在可移植标识符子集中保留 `$`。** 拒绝:`$` 是 JS 专有拼写。允许它会让 `$tools` 通过 worker 却在每个非 JS 后端上失败,为纯粹表面的好处破坏可移植性。 + +## Consequences + +获得:一个地方——seam 包——定义什么是可移植绑定名称,每个后端通过导入执行同一契约。在一个后端上有效的命名空间列表在所有后端上都有效,这是可验证的,而非取决于调用方测试了哪个后端的巧合。 + +代价:现有使用含 `$` global 的 worker 调用方现在会在标识符校验时失败。在预发布立场下这是一次被纠正的地基,而非需要 shim 的兼容性破坏。worker 的 seam-misuse 测试新增了 `$tools`、Python 异常成员(`args`)、dunder(`__dict__`)与一个 Python 拥有的 global(`__dsh_main__`)等用例,从 worker 侧证明共享集合被执行。 diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index be156c85ba..3c198ca94e 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url' import { Context } from 'cordis' import z from 'schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime' import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' @@ -65,20 +65,27 @@ const ELU_POLL_INTERVAL_MS = 25 /** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */ const MIN_OUTPUT_BYTES = 4 -/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */ -const RESERVED_WORDS = new Set([ - 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', - 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', - 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', - 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package', - 'private', 'protected', 'public', 'arguments', 'eval', -]) +/** + * The seam's cross-language reserved-word union: the portable-identifier + * contract promises a namespace list valid here is valid on every backend, so + * a Python keyword like `lambda` is refused even though it is a legal JS + * parameter name. + */ +const RESERVED_WORDS = PORTABLE_RESERVED_WORDS -/** Valid async-function parameter name (the binding global becomes one). */ -const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ +/** + * The seam's language-portable identifier subset (see + * `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same + * namespace list must be usable against every backend regardless of language. + */ +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ -/** Error properties whose binding-member replacement would destroy the promised Error contract. */ -const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack']) +/** + * The seam's shared error-member exclusions (plus the dunder rule below): + * enforced identically here and in the Python backend so an errorClass valid + * on one backend is valid on all. + */ +const RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS /** * The shell a program is wrapped in for the type-strip, matching the @@ -331,7 +338,11 @@ export class WorkerCodeRuntime extends CodeRuntime { if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`) } - if (namespace.global === 'console' || bindings.has(namespace.global)) { + // RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set: + // `console` is THIS backend's log-capture slot; the dunder entries are + // the Python bootstrap's — refused here too so the namespace list stays + // portable across backends. + if (RESERVED_BINDING_GLOBALS.has(namespace.global) || bindings.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`) } bindings.set(namespace.global, namespace) @@ -344,10 +355,11 @@ export class WorkerCodeRuntime extends CodeRuntime { if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) { throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`) } - if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) { + if (RESERVED_BINDING_GLOBALS.has(descriptor.name) || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) { throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`) } - if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) { + const member = descriptor.memberNameProperty + if (member.length === 0 || RESERVED_ERROR_PROPERTIES.has(member) || DUNDER_MEMBER.test(member)) { throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`) } errorClassNames.add(descriptor.name) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index b16e2b5671..ce97faadb3 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -787,6 +787,9 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { const cases: [string, RegExp][] = [ ['not valid!', /not a usable identifier/], ['await', /not a usable identifier/], + // `$tools` is legal JS but outside the seam's language-portable subset: + // the same namespace list must work against every backend's language. + ['$tools', /not a usable identifier/], ['console', /duplicate binding global/], ] for (const [global, message] of cases) { @@ -822,6 +825,14 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { ])).rejects.toThrow(/duplicate injected global/) await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/) await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/) + // The shared exclusion set covers Python's exception-protocol members and + // dunders too, so the same errorClass is valid (or not) on every backend. + await expect(run([namespace('tools', 'CallError', 'args')])).rejects.toThrow(/member property.*not usable/) + await expect(run([namespace('tools', 'CallError', '__dict__')])).rejects.toThrow(/member property.*not usable/) + // The Python bootstrap's owned globals are refused here too (shared + // RESERVED_BINDING_GLOBALS), keeping namespace lists backend-portable. + await expect(runtime.run({ program: 'return 1', bindings: [{ global: '__dsh_main__', functions: {} }] })) + .rejects.toThrow(/duplicate binding global/) }) it('rejects config values that are not positive numbers', async () => { diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index bd52b9ed29..681c0465a7 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -17,6 +17,68 @@ export type { CodeRunResult, } from './types.ts' +/** + * Binding globals EVERY backend refuses because SOME backend owns the slot in + * the program's namespace: `console` (the worker's log capture), and + * `__dsh_main__`/`__builtins__`/`__name__` (the Python bootstrap's wrapper + * and seeded module globals), and `__debug__`. One shared set — rather than each backend + * refusing only its own slots — keeps the portability promise real: a + * namespace list valid on one backend is valid on all, so a caller cannot + * pick a name that works on the worker and collides on Python (or vice + * versa). Dunder-form names are additionally covered by the identifier rule + * on `CodeBindingNamespace.global` only when they fail it; `__name__` et al. + * ARE valid identifiers, hence this explicit set. `__debug__` is listed for a + * different reason than a collision: CPython compiles a bare `__debug__` + * reference to the constant `True` and rejects any assignment to the name at + * COMPILE time, so an injected global under that name is unreachable from the + * program — accepted by validation, unusable on the Python backend, which is + * exactly the split the shared set exists to prevent. + */ +export const RESERVED_BINDING_GLOBALS: ReadonlySet<string> = new Set([ + 'console', + '__dsh_main__', '__builtins__', '__name__', '__debug__', +]) + +/** + * `CodeBindingErrorClass.memberNameProperty` names EVERY backend refuses, as + * one shared contract so a request valid on one backend is valid on all. The + * JS `Error` exclusions (`name`, `message`, `stack`) and Python's + * exception-protocol members (`args`, `with_traceback`, `add_note`) are + * listed by name; dunder-form names (`__*__`) are refused wholesale — several + * are constrained CPython descriptors whose `setattr` raises while + * constructing the rejection, and the exact set is an interpreter version + * detail. Any other non-empty own property name is accepted everywhere. + */ +export const RESERVED_ERROR_MEMBERS: ReadonlySet<string> = new Set([ + 'name', 'message', 'stack', + 'args', 'with_traceback', 'add_note', +]) + +/** Dunder form (`__*__`): object-protocol slots in Python, refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ +export const DUNDER_MEMBER = /^__.*__$/ + +/** + * Reserved words of EVERY shipped backend language (ECMAScript ∪ Python), + * refused as {@link CodeBindingNamespace.global} / error-class names by all + * backends. The portable-identifier contract promises a namespace list valid + * on one backend is valid on every backend; a per-language check would let + * `lambda` pass the TypeScript backend and fail the Python one. Extending the + * seam with a new language means widening this union (a breaking review of + * existing binding names, by design). + */ +export const PORTABLE_RESERVED_WORDS: ReadonlySet<string> = new Set([ + // ECMAScript reserved words and reserved-in-strict-mode names. + 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', + 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', + 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', + 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package', + 'private', 'protected', 'public', 'arguments', 'eval', + // Python 3.x keywords and soft keywords not already above ('type' and '_' + // are soft keywords: legal names in practice, reserved here for safety). + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'def', 'del', 'elif', 'except', 'from', + 'global', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'match', 'type', '_', +]) + declare module 'cordis' { interface Context { codeRuntime: CodeRuntime diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts index a53353799b..e96d8bc959 100644 --- a/packages/code-runtime/code-runtime/src/types.ts +++ b/packages/code-runtime/code-runtime/src/types.ts @@ -28,9 +28,14 @@ export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | * of a particular consumer such as Code Mode. */ export interface CodeBindingErrorClass { - /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */ + /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ name: string - /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */ + /** + * Non-empty own property for the member name. The portable exclusion set is + * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__*__`), enforced + * identically by every backend; any other name — identifiers or not — is + * accepted everywhere. + */ memberNameProperty: string } @@ -42,7 +47,13 @@ export interface CodeBindingErrorClass { * collisions. */ export interface CodeBindingNamespace { - /** The global identifier the program sees (must be a valid JS identifier). */ + /** + * The global identifier the program sees. Must match the LANGUAGE-PORTABLE + * identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved + * words, so the same namespace list works against every backend regardless + * of `language` — a JS-only spelling like `$tools` is rejected by design, + * not just by the Python backend. + */ global: string /** The callable members, keyed by the exact name the program calls. */ functions: Record<string, CodeBindingFunction> diff --git a/packages/code-runtime/code-runtime/tests/reserved.spec.ts b/packages/code-runtime/code-runtime/tests/reserved.spec.ts new file mode 100644 index 0000000000..31868252a1 --- /dev/null +++ b/packages/code-runtime/code-runtime/tests/reserved.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + DUNDER_MEMBER, + PORTABLE_RESERVED_WORDS, + RESERVED_BINDING_GLOBALS, + RESERVED_ERROR_MEMBERS, +} from '@deepseek-ai/dsh-code-runtime' + +/** + * The seam owns the portable-identifier exclusion sets so every backend + * enforces one contract: a namespace list valid on one backend is valid on + * all. These assertions pin the shared membership backends import rather than + * re-declare. + */ +describe('seam-owned portable identifier exclusions', () => { + it('RESERVED_BINDING_GLOBALS covers each backend-owned slot', () => { + expect(RESERVED_BINDING_GLOBALS.has('console')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('__dsh_main__')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('__builtins__')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('__name__')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('__debug__')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('tools')).toBe(false) + }) + + it('RESERVED_ERROR_MEMBERS covers the JS Error and Python exception-protocol members', () => { + for (const name of ['name', 'message', 'stack', 'args', 'with_traceback', 'add_note']) { + expect(RESERVED_ERROR_MEMBERS.has(name)).toBe(true) + } + expect(RESERVED_ERROR_MEMBERS.has('code')).toBe(false) + }) + + it('DUNDER_MEMBER matches dunder-form names only', () => { + expect(DUNDER_MEMBER.test('__dict__')).toBe(true) + expect(DUNDER_MEMBER.test('__init__')).toBe(true) + expect(DUNDER_MEMBER.test('_private')).toBe(false) + expect(DUNDER_MEMBER.test('name')).toBe(false) + expect(DUNDER_MEMBER.test('__mid')).toBe(false) + }) + + it('PORTABLE_RESERVED_WORDS is the union of ECMAScript and Python reserved words', () => { + // ECMAScript-only keyword. + expect(PORTABLE_RESERVED_WORDS.has('function')).toBe(true) + // Python-only keyword — refused here so the list stays portable. + expect(PORTABLE_RESERVED_WORDS.has('lambda')).toBe(true) + expect(PORTABLE_RESERVED_WORDS.has('nonlocal')).toBe(true) + // Shared keyword. + expect(PORTABLE_RESERVED_WORDS.has('class')).toBe(true) + // Ordinary identifier is not reserved. + expect(PORTABLE_RESERVED_WORDS.has('tools')).toBe(false) + }) +}) From b5578c026e2c00b2efc922dddfe16c61ffadf578 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Fri, 31 Jul 2026 18:17:51 +0800 Subject: [PATCH 173/190] docs(code-runtime): align derived docs with the portable-identifier JSDoc Update the type-equiv blocks in docs/core-data-structures/code-runtime (both languages, re-recorded) to mirror the new CodeBindingNamespace / CodeBindingErrorClass JSDoc, and regenerate the cordis services catalog for the shifted source line. Keeps the worker-only intro until the Python backend package exists. --- docs/cordis-catalog/services.md | 2 +- .../core-data-structures/code-runtime.i18n.yaml | 4 ++-- docs/core-data-structures/code-runtime.md | 17 ++++++++++++++--- docs/core-data-structures/code-runtime.zh.md | 17 ++++++++++++++--- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2ded3e4f07..07ae7bf044 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -398,7 +398,7 @@ abstract run(request: CodeRunRequest): Promise<CodeRunResult> Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:33`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:95`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.commands` — `CommandService` diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index fbdee4c938..ac7dae06af 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/code-runtime.md -code-runtime.md: 64de3c45d4f1d1d981daa6c6f074abb667e0aa52 -code-runtime.zh.md: daf07aaf613852a6c4a7b1aff152fcc61052fbca +code-runtime.md: 522fe29deae8cac01462a336a02d082502b61fb4 +code-runtime.zh.md: 6d16f8ed2ab3b99620f30a8e81dc3718cf1419d1 diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index 64de3c45d4..522fe29dea 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -72,9 +72,14 @@ Each `CodeBindingNamespace` becomes one global object of async callables inside * of a particular consumer such as Code Mode. */ interface CodeBindingErrorClass { - /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */ + /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ name: string - /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */ + /** + * Non-empty own property for the member name. The portable exclusion set is + * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__*__`), enforced + * identically by every backend; any other name — identifiers or not — is + * accepted everywhere. + */ memberNameProperty: string } ``` @@ -88,7 +93,13 @@ interface CodeBindingErrorClass { * collisions. */ interface CodeBindingNamespace { - /** The global identifier the program sees (must be a valid JS identifier). */ + /** + * The global identifier the program sees. Must match the LANGUAGE-PORTABLE + * identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved + * words, so the same namespace list works against every backend regardless + * of `language` — a JS-only spelling like `$tools` is rejected by design, + * not just by the Python backend. + */ global: string /** The callable members, keyed by the exact name the program calls. */ functions: Record<string, CodeBindingFunction> diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index daf07aaf61..6d16f8ed2a 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -72,9 +72,14 @@ interface CodeRunResult { * of a particular consumer such as Code Mode. */ interface CodeBindingErrorClass { - /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */ + /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ name: string - /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */ + /** + * Non-empty own property for the member name. The portable exclusion set is + * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__*__`), enforced + * identically by every backend; any other name — identifiers or not — is + * accepted everywhere. + */ memberNameProperty: string } ``` @@ -88,7 +93,13 @@ interface CodeBindingErrorClass { * collisions. */ interface CodeBindingNamespace { - /** The global identifier the program sees (must be a valid JS identifier). */ + /** + * The global identifier the program sees. Must match the LANGUAGE-PORTABLE + * identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved + * words, so the same namespace list works against every backend regardless + * of `language` — a JS-only spelling like `$tools` is rejected by design, + * not just by the Python backend. + */ global: string /** The callable members, keyed by the exact name the program calls. */ functions: Record<string, CodeBindingFunction> From eb03aa86fec91dd92cb303834596141c0ad1f566 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Fri, 31 Jul 2026 18:30:32 +0800 Subject: [PATCH 174/190] =?UTF-8?q?refactor(code-runtime):=20address=20sea?= =?UTF-8?q?m=20review=20=E2=80=94=20drop=20worker=20aliases,=20tighten=20d?= =?UTF-8?q?under?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worker consumes PORTABLE_RESERVED_WORDS / RESERVED_ERROR_MEMBERS by their seam names directly, dropping the local re-alias (symmetry with the other two imported constants). - Split the reserved-vs-duplicate diagnostics: a backend-owned global now reports "reserved binding global", not the misleading "duplicate". - DUNDER_MEMBER uses `__.+__` so a bare `__` (empty middle, not a real CPython dunder) is not matched; add coverage. - Worker misuse tests add `a$b` (second-char `$`) and `lambda` (Python keyword) so the identifier narrowing and reserved-word adoption are each pinned directly, not only transitively. - Clarify the seam JSDoc (dunder-vs-explicit-set wording, Python backend is a later stack PR) and record in the Agent Note the obligation to widen RESERVED_BINDING_GLOBALS when the bootstrap seeds more globals. --- ...runtime-portable-identifier-seam.i18n.yaml | 4 +- ...1-code-runtime-portable-identifier-seam.md | 2 + ...ode-runtime-portable-identifier-seam.zh.md | 2 + .../code-runtime-worker/src/index.ts | 33 ++++++----------- .../code-runtime-worker/tests/runtime.spec.ts | 15 ++++++-- .../code-runtime/code-runtime/src/index.ts | 37 +++++++++++-------- .../code-runtime/tests/reserved.spec.ts | 3 ++ 7 files changed, 54 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml index 73bf26efee..a2a0848eab 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-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/architecture/2026-07-31-code-runtime-portable-identifier-seam.md -2026-07-31-code-runtime-portable-identifier-seam.md: a24a2c03c937a7d569528504b5bd5fba812009a2 -2026-07-31-code-runtime-portable-identifier-seam.zh.md: 1bef607e31a5820ba849f09eb7ae4cc781c45a84 +2026-07-31-code-runtime-portable-identifier-seam.md: bd1876fc459dc85bb5d9f42ac04f4cc801d6398a +2026-07-31-code-runtime-portable-identifier-seam.zh.md: 2fd5e97c562d1e51cbad906cd37998b8ffccc58a diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md index a24a2c03c9..bd1876fc45 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md @@ -27,6 +27,8 @@ The constants live at the seam even though only one backend ships in this PR: th This PR delivers only the seam extension and the worker's adoption of it. No Python backend, `py-types` renderer, or Code Mode language dispatch ships here — they are later PRs in the stack that depend on these exports. The seam README's worker-only wording is left unchanged for the same reason: linking to a `dsh-code-runtime-python` README that does not yet exist would break the dead-link gate. +`RESERVED_BINDING_GLOBALS` currently encodes the not-yet-merged Python bootstrap's concrete design: it seeds exactly `__builtins__`/`__name__` and wraps the program under `__dsh_main__`. The Python-backend PR that seeds any additional module global (`__doc__`, `__loader__`, `__spec__`, `__file__`, `__package__`, …) MUST widen this set in the same change, exactly as adding a language widens `PORTABLE_RESERVED_WORDS` — a name the bootstrap seeds but the set omits is the portability split this contract exists to prevent. + ## Alternatives considered **Each backend declares its own exclusions.** Rejected: it makes the portability promise per-backend. A binding list the caller tested on the worker could be refused by Python, which is exactly the split the seam exists to prevent. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md index 1bef607e31..2fd5e97c56 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md @@ -27,6 +27,8 @@ seam 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0-9_]*`(记 本 PR 只交付 seam 扩展与 worker 对它的采用。这里不交付任何 Python 后端、`py-types` 渲染器或 Code Mode 的语言分发——它们是本 stack 中依赖这些导出的后续 PR。seam README 中仅描述 worker 的措辞保持不变,理由相同:链接到一个尚不存在的 `dsh-code-runtime-python` README 会破坏死链 gate。 +`RESERVED_BINDING_GLOBALS` 当前编码了尚未合并的 Python bootstrap 的具体设计:它恰好 seed `__builtins__`/`__name__`,并把程序包装在 `__dsh_main__` 之下。任何 seed 额外模块 global(`__doc__`、`__loader__`、`__spec__`、`__file__`、`__package__` 等)的 Python 后端 PR 必须在同一改动中扩宽此集合,正如新增一门语言即扩宽 `PORTABLE_RESERVED_WORDS`——bootstrap 会 seed 却不在集合中的名称,正是本契约要防止的可移植性分裂。 + ## Alternatives considered **每个后端声明自己的排除集。** 拒绝:这让可移植承诺变成逐后端成立。调用方在 worker 上测过的绑定列表可能被 Python 拒绝,而这正是 seam 存在要防止的分裂。 diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index 3c198ca94e..9773d3c341 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -65,14 +65,6 @@ const ELU_POLL_INTERVAL_MS = 25 /** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */ const MIN_OUTPUT_BYTES = 4 -/** - * The seam's cross-language reserved-word union: the portable-identifier - * contract promises a namespace list valid here is valid on every backend, so - * a Python keyword like `lambda` is refused even though it is a legal JS - * parameter name. - */ -const RESERVED_WORDS = PORTABLE_RESERVED_WORDS - /** * The seam's language-portable identifier subset (see * `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same @@ -80,13 +72,6 @@ const RESERVED_WORDS = PORTABLE_RESERVED_WORDS */ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ -/** - * The seam's shared error-member exclusions (plus the dunder rule below): - * enforced identically here and in the Python backend so an errorClass valid - * on one backend is valid on all. - */ -const RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS - /** * The shell a program is wrapped in for the type-strip, matching the * grammatical context it will execute in (an async function body, where @@ -335,14 +320,17 @@ export class WorkerCodeRuntime extends CodeRuntime { private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> { const bindings = new Map<string, CodeBindingNamespace>() for (const namespace of request.bindings) { - if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) { + if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`) } // RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set: // `console` is THIS backend's log-capture slot; the dunder entries are - // the Python bootstrap's — refused here too so the namespace list stays + // the Python backend's — refused here too so the namespace list stays // portable across backends. - if (RESERVED_BINDING_GLOBALS.has(namespace.global) || bindings.has(namespace.global)) { + if (RESERVED_BINDING_GLOBALS.has(namespace.global)) { + throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(namespace.global)}`) + } + if (bindings.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`) } bindings.set(namespace.global, namespace) @@ -352,14 +340,17 @@ export class WorkerCodeRuntime extends CodeRuntime { for (const namespace of request.bindings) { const descriptor = namespace.errorClass if (!descriptor) continue - if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) { + if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) { throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`) } - if (RESERVED_BINDING_GLOBALS.has(descriptor.name) || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) { + if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) { + throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(descriptor.name)}`) + } + if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) { throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`) } const member = descriptor.memberNameProperty - if (member.length === 0 || RESERVED_ERROR_PROPERTIES.has(member) || DUNDER_MEMBER.test(member)) { + if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) { throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`) } errorClassNames.add(descriptor.name) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index ce97faadb3..54f58eb414 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -790,7 +790,14 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { // `$tools` is legal JS but outside the seam's language-portable subset: // the same namespace list must work against every backend's language. ['$tools', /not a usable identifier/], - ['console', /duplicate binding global/], + // `a$b` pins the second character class too: the old identifier regex + // `[A-Za-z0-9_$]*` would have accepted a `$` after the first character. + ['a$b', /not a usable identifier/], + // `lambda` is a Python keyword, refused here directly (not just + // transitively) so the worker's adoption of PORTABLE_RESERVED_WORDS is + // its own regression, symmetric with the `$tools` case. + ['lambda', /not a usable identifier/], + ['console', /reserved binding global/], ] for (const [global, message] of cases) { await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message) @@ -817,7 +824,7 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/) await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/) - await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/) + await expect(run([namespace('tools', 'console')])).rejects.toThrow(/reserved binding global/) await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/) await expect(run([ namespace('tools', 'CallError'), @@ -829,10 +836,10 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { // dunders too, so the same errorClass is valid (or not) on every backend. await expect(run([namespace('tools', 'CallError', 'args')])).rejects.toThrow(/member property.*not usable/) await expect(run([namespace('tools', 'CallError', '__dict__')])).rejects.toThrow(/member property.*not usable/) - // The Python bootstrap's owned globals are refused here too (shared + // The Python backend's owned globals are refused here too (shared // RESERVED_BINDING_GLOBALS), keeping namespace lists backend-portable. await expect(runtime.run({ program: 'return 1', bindings: [{ global: '__dsh_main__', functions: {} }] })) - .rejects.toThrow(/duplicate binding global/) + .rejects.toThrow(/reserved binding global/) }) it('rejects config values that are not positive numbers', async () => { diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 681c0465a7..2e4f18a2e2 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -20,19 +20,23 @@ export type { /** * Binding globals EVERY backend refuses because SOME backend owns the slot in * the program's namespace: `console` (the worker's log capture), and - * `__dsh_main__`/`__builtins__`/`__name__` (the Python bootstrap's wrapper - * and seeded module globals), and `__debug__`. One shared set — rather than each backend - * refusing only its own slots — keeps the portability promise real: a - * namespace list valid on one backend is valid on all, so a caller cannot - * pick a name that works on the worker and collides on Python (or vice - * versa). Dunder-form names are additionally covered by the identifier rule - * on `CodeBindingNamespace.global` only when they fail it; `__name__` et al. - * ARE valid identifiers, hence this explicit set. `__debug__` is listed for a - * different reason than a collision: CPython compiles a bare `__debug__` - * reference to the constant `True` and rejects any assignment to the name at - * COMPILE time, so an injected global under that name is unreachable from the - * program — accepted by validation, unusable on the Python backend, which is - * exactly the split the shared set exists to prevent. + * `__dsh_main__`/`__builtins__`/`__name__` (the Python backend's bootstrap + * wrapper and seeded module globals — that backend is a later PR in this + * stack, see the [portable-identifier Agent + * Note](../../../../.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md)), + * and `__debug__`. One shared set — rather than each backend refusing only its + * own slots — keeps the portability promise real: a namespace list valid on + * one backend is valid on all, so a caller cannot pick a name that works on + * the worker and collides on Python (or vice versa). `__name__` et al. ARE + * valid portable identifiers, so the identifier rule on + * `CodeBindingNamespace.global` never rejects them — hence this explicit set. + * (Error members differ: {@link DUNDER_MEMBER} refuses every dunder form + * wholesale; binding globals refuse only the names listed here.) `__debug__` + * is listed for a different reason than a collision: CPython compiles a bare + * `__debug__` reference to the constant `True` and rejects any assignment to + * the name at COMPILE time, so an injected global under that name is + * unreachable from the program — accepted by validation, unusable on the + * Python backend, which is exactly the split the shared set exists to prevent. */ export const RESERVED_BINDING_GLOBALS: ReadonlySet<string> = new Set([ 'console', @@ -54,8 +58,11 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet<string> = new Set([ 'args', 'with_traceback', 'add_note', ]) -/** Dunder form (`__*__`): object-protocol slots in Python, refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ -export const DUNDER_MEMBER = /^__.*__$/ +/** + * Dunder form (`__x__`, non-empty middle): object-protocol slots in Python, + * refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. + */ +export const DUNDER_MEMBER = /^__.+__$/ /** * Reserved words of EVERY shipped backend language (ECMAScript ∪ Python), diff --git a/packages/code-runtime/code-runtime/tests/reserved.spec.ts b/packages/code-runtime/code-runtime/tests/reserved.spec.ts index 31868252a1..ccd2c89211 100644 --- a/packages/code-runtime/code-runtime/tests/reserved.spec.ts +++ b/packages/code-runtime/code-runtime/tests/reserved.spec.ts @@ -35,6 +35,9 @@ describe('seam-owned portable identifier exclusions', () => { expect(DUNDER_MEMBER.test('_private')).toBe(false) expect(DUNDER_MEMBER.test('name')).toBe(false) expect(DUNDER_MEMBER.test('__mid')).toBe(false) + // `__` has an empty middle — not a real CPython dunder, so not matched. + expect(DUNDER_MEMBER.test('__')).toBe(false) + expect(DUNDER_MEMBER.test('____')).toBe(true) }) it('PORTABLE_RESERVED_WORDS is the union of ECMAScript and Python reserved words', () => { From 0e6fe760c1407ca19fb8b5c8d4e8d2346b5bf383 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Fri, 31 Jul 2026 21:54:57 +0800 Subject: [PATCH 175/190] docs(code-runtime): regenerate cordis services catalog for shifted source line --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 07ae7bf044..6c670942b2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -398,7 +398,7 @@ abstract run(request: CodeRunRequest): Promise<CodeRunResult> Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:95`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:102`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.commands` — `CommandService` From 4dc2b197d737a5468a40cb62c5f379d2fbf0762a Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sat, 1 Aug 2026 01:55:10 +0800 Subject: [PATCH 176/190] test(code-runtime): correct DUNDER_MEMBER edge cases for the __.+__ pattern `____` has an empty middle between the two `__` pairs and does not match `/^__.+__$/`; assert that (not a match) and add `__x__` as the shortest real dunder form that does. --- packages/code-runtime/code-runtime/tests/reserved.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/code-runtime/code-runtime/tests/reserved.spec.ts b/packages/code-runtime/code-runtime/tests/reserved.spec.ts index ccd2c89211..f93b63b5ef 100644 --- a/packages/code-runtime/code-runtime/tests/reserved.spec.ts +++ b/packages/code-runtime/code-runtime/tests/reserved.spec.ts @@ -37,7 +37,10 @@ describe('seam-owned portable identifier exclusions', () => { expect(DUNDER_MEMBER.test('__mid')).toBe(false) // `__` has an empty middle — not a real CPython dunder, so not matched. expect(DUNDER_MEMBER.test('__')).toBe(false) - expect(DUNDER_MEMBER.test('____')).toBe(true) + // `____` also has an empty middle between the two `__` pairs — not matched. + expect(DUNDER_MEMBER.test('____')).toBe(false) + // A single character between the pairs is the shortest real dunder form. + expect(DUNDER_MEMBER.test('__x__')).toBe(true) }) it('PORTABLE_RESERVED_WORDS is the union of ECMAScript and Python reserved words', () => { From c05db227d545ca5e70f8f6af1e29b42f7ffbcb58 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sat, 1 Aug 2026 05:54:04 +0800 Subject: [PATCH 177/190] docs(code-runtime): sync Agent Note and public JSDoc with the shipped seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent Note: correct the stale worker-adoption paragraph — the worker consumes the seam constants directly by name (no RESERVED_WORDS / RESERVED_ERROR_PROPERTIES re-alias) — and describe DUNDER_MEMBER as `__x__` (non-empty middle). - types.ts: document RESERVED_BINDING_GLOBALS on CodeBindingNamespace.global (names like `__dsh_main__` pass the identifier rule but are still refused) and the non-empty-middle dunder rule on memberNameProperty; propagate to the type-equiv derivative docs (both languages, re-recorded). --- ...31-code-runtime-portable-identifier-seam.i18n.yaml | 4 ++-- ...026-07-31-code-runtime-portable-identifier-seam.md | 4 ++-- ...-07-31-code-runtime-portable-identifier-seam.zh.md | 4 ++-- docs/core-data-structures/code-runtime.i18n.yaml | 4 ++-- docs/core-data-structures/code-runtime.md | 11 +++++++---- docs/core-data-structures/code-runtime.zh.md | 11 +++++++---- packages/code-runtime/code-runtime/src/types.ts | 11 +++++++---- 7 files changed, 29 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml index a2a0848eab..be124ffcbd 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-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/architecture/2026-07-31-code-runtime-portable-identifier-seam.md -2026-07-31-code-runtime-portable-identifier-seam.md: bd1876fc459dc85bb5d9f42ac04f4cc801d6398a -2026-07-31-code-runtime-portable-identifier-seam.zh.md: 2fd5e97c562d1e51cbad906cd37998b8ffccc58a +2026-07-31-code-runtime-portable-identifier-seam.md: 72796458d75dc04d094372f13d7253a6a4dad511 +2026-07-31-code-runtime-portable-identifier-seam.zh.md: ce709d5924d0835cd475af6d439c07e1dbceecd0 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md index bd1876fc45..72796458d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md @@ -17,9 +17,9 @@ The seam package (`@deepseek-ai/dsh-code-runtime`) exports the portable-identifi - `PORTABLE_RESERVED_WORDS` — the union of ECMAScript and Python reserved words. A namespace global or error-class name matching any is refused on all backends, so `lambda` is refused even though it is a legal JS parameter name. Adding a language widens this union, which is a deliberate breaking review of existing binding names. - `RESERVED_BINDING_GLOBALS` — globals some backend owns in the program's namespace: `console` (the worker's log capture) and `__dsh_main__`/`__builtins__`/`__name__`/`__debug__` (the Python bootstrap's wrapper and seeded module globals). Refused everywhere so a namespace list cannot pick a name that works on one backend and collides on another. - `RESERVED_ERROR_MEMBERS` — error-member names every backend refuses: the JS `Error` slots (`name`, `message`, `stack`) and Python's exception-protocol members (`args`, `with_traceback`, `add_note`). -- `DUNDER_MEMBER` — the dunder-form regex (`__*__`), refused as an error member wholesale because several are constrained CPython descriptors whose exact set is an interpreter-version detail. +- `DUNDER_MEMBER` — the dunder-form regex (`__x__`, non-empty middle), refused as an error member wholesale because several are constrained CPython descriptors whose exact set is an interpreter-version detail. -The seam also narrows the portable identifier subset to `[A-Za-z_][A-Za-z0-9_]*` (documented on `CodeBindingNamespace.global` and `CodeBindingErrorClass`), dropping the JS-only `$`. The worker consumes the shared constants: `RESERVED_WORDS = PORTABLE_RESERVED_WORDS`, `RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS`, its `IDENTIFIER` regex loses `$`, and its error-member check adds `DUNDER_MEMBER`. +The seam also narrows the portable identifier subset to `[A-Za-z_][A-Za-z0-9_]*` (documented on `CodeBindingNamespace.global` and `CodeBindingErrorClass`), dropping the JS-only `$`. The worker consumes the shared constants directly by their seam names — `PORTABLE_RESERVED_WORDS` for both binding-global and error-class names, `RESERVED_BINDING_GLOBALS` for backend-owned slots, `RESERVED_ERROR_MEMBERS` plus `DUNDER_MEMBER` for error members — with no local re-alias; its `IDENTIFIER` regex loses `$`. The constants live at the seam even though only one backend ships in this PR: the whole point is that the contract is language-agnostic and owned above any single language. A backend that violated it would be the bug, and the shared set is where a reviewer looks to see what "portable" means. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md index 2fd5e97c56..ce709d5924 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md @@ -17,9 +17,9 @@ seam 包(`@deepseek-ai/dsh-code-runtime`)以四个具名常量导出可移 - `PORTABLE_RESERVED_WORDS`——ECMAScript 与 Python 保留字的联集。任何命名空间 global 或 error-class 名称匹配其中之一,都在所有后端上被拒绝,因此 `lambda` 即便是合法的 JS 参数名也被拒绝。新增一门语言即扩宽此联集,这是对现有绑定名称的一次有意的破坏性复审。 - `RESERVED_BINDING_GLOBALS`——某个后端在程序命名空间中拥有的 global:`console`(worker 的日志捕获)与 `__dsh_main__`/`__builtins__`/`__name__`/`__debug__`(Python bootstrap 的包装器与预置模块 global)。在所有后端上被拒绝,使命名空间列表无法选到一个在某后端能用、在另一后端冲突的名称。 - `RESERVED_ERROR_MEMBERS`——每个后端都拒绝的 error-member 名称:JS `Error` 槽位(`name`、`message`、`stack`)与 Python 异常协议成员(`args`、`with_traceback`、`add_note`)。 -- `DUNDER_MEMBER`——dunder 形式正则(`__*__`),作为 error member 被整体拒绝,因为其中若干是受约束的 CPython 描述符,其确切集合是解释器版本细节。 +- `DUNDER_MEMBER`——dunder 形式正则(`__x__`,非空中缀),作为 error member 被整体拒绝,因为其中若干是受约束的 CPython 描述符,其确切集合是解释器版本细节。 -seam 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0-9_]*`(记录在 `CodeBindingNamespace.global` 与 `CodeBindingErrorClass` 上),去掉 JS 专有的 `$`。worker 消费这些共享常量:`RESERVED_WORDS = PORTABLE_RESERVED_WORDS`、`RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS`,其 `IDENTIFIER` 正则去掉 `$`,其 error-member 检查加上 `DUNDER_MEMBER`。 +seam 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0-9_]*`(记录在 `CodeBindingNamespace.global` 与 `CodeBindingErrorClass` 上),去掉 JS 专有的 `$`。worker 直接以 seam 名消费这些共享常量——binding-global 与 error-class 名称用 `PORTABLE_RESERVED_WORDS`、后端拥有槽位用 `RESERVED_BINDING_GLOBALS`、error member 用 `RESERVED_ERROR_MEMBERS` 加 `DUNDER_MEMBER`——不再本地起别名;其 `IDENTIFIER` 正则去掉 `$`。 尽管本 PR 只交付一个后端,这些常量仍置于 seam:要点正是该契约与语言无关,且拥有权在任何单一语言之上。违反它的后端才是 bug,而共享集合正是复审者查看"可移植"含义的地方。 diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index ac7dae06af..c4bbc64d27 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/code-runtime.md -code-runtime.md: 522fe29deae8cac01462a336a02d082502b61fb4 -code-runtime.zh.md: 6d16f8ed2ab3b99620f30a8e81dc3718cf1419d1 +code-runtime.md: e7b5a86513abcab90eb81fc0b24296760bf3b926 +code-runtime.zh.md: 17ddbb0f685d97ba3c2dd6a23744771e88a40ed0 diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index 522fe29dea..e7b5a86513 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -76,9 +76,9 @@ interface CodeBindingErrorClass { name: string /** * Non-empty own property for the member name. The portable exclusion set is - * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__*__`), enforced - * identically by every backend; any other name — identifiers or not — is - * accepted everywhere. + * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__x__`, non-empty + * middle), enforced identically by every backend; any other name — + * identifiers or not — is accepted everywhere. */ memberNameProperty: string } @@ -98,7 +98,10 @@ interface CodeBindingNamespace { * identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved * words, so the same namespace list works against every backend regardless * of `language` — a JS-only spelling like `$tools` is rejected by design, - * not just by the Python backend. + * not just by the Python backend. Names that satisfy the identifier rule but + * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`: `console`, + * `__dsh_main__`, `__builtins__`, `__name__`, `__debug__`) are also refused + * everywhere, since some backend seeds that slot in the program's namespace. */ global: string /** The callable members, keyed by the exact name the program calls. */ diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index 6d16f8ed2a..17ddbb0f68 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -76,9 +76,9 @@ interface CodeBindingErrorClass { name: string /** * Non-empty own property for the member name. The portable exclusion set is - * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__*__`), enforced - * identically by every backend; any other name — identifiers or not — is - * accepted everywhere. + * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__x__`, non-empty + * middle), enforced identically by every backend; any other name — + * identifiers or not — is accepted everywhere. */ memberNameProperty: string } @@ -98,7 +98,10 @@ interface CodeBindingNamespace { * identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved * words, so the same namespace list works against every backend regardless * of `language` — a JS-only spelling like `$tools` is rejected by design, - * not just by the Python backend. + * not just by the Python backend. Names that satisfy the identifier rule but + * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`: `console`, + * `__dsh_main__`, `__builtins__`, `__name__`, `__debug__`) are also refused + * everywhere, since some backend seeds that slot in the program's namespace. */ global: string /** The callable members, keyed by the exact name the program calls. */ diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts index e96d8bc959..e6c3b5ddc2 100644 --- a/packages/code-runtime/code-runtime/src/types.ts +++ b/packages/code-runtime/code-runtime/src/types.ts @@ -32,9 +32,9 @@ export interface CodeBindingErrorClass { name: string /** * Non-empty own property for the member name. The portable exclusion set is - * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__*__`), enforced - * identically by every backend; any other name — identifiers or not — is - * accepted everywhere. + * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__x__`, non-empty + * middle), enforced identically by every backend; any other name — + * identifiers or not — is accepted everywhere. */ memberNameProperty: string } @@ -52,7 +52,10 @@ export interface CodeBindingNamespace { * identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved * words, so the same namespace list works against every backend regardless * of `language` — a JS-only spelling like `$tools` is rejected by design, - * not just by the Python backend. + * not just by the Python backend. Names that satisfy the identifier rule but + * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`: `console`, + * `__dsh_main__`, `__builtins__`, `__name__`, `__debug__`) are also refused + * everywhere, since some backend seeds that slot in the program's namespace. */ global: string /** The callable members, keyed by the exact name the program calls. */ From efdc78cba3e9094a73b2e0433f806b466b7ffdcd Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sat, 1 Aug 2026 13:57:49 +0800 Subject: [PATCH 178/190] docs(code-runtime): fix remaining dunder/backend wording and document exports in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.ts: RESERVED_ERROR_MEMBERS JSDoc now says `__x__` (non-empty middle), matching DUNDER_MEMBER and the derivative docs; and PORTABLE_RESERVED_WORDS is described as covering portable *target* languages (Python is a later-PR backend, not yet shipped). - types.ts + type-equiv docs: CodeBindingNamespace.global points to RESERVED_BINDING_GLOBALS by name with an `e.g.` sample rather than enumerating the set (single home), no longer implying `__debug__` is a seeded slot. - Agent Note: separate `__debug__` (CPython compile-time constant) from the seeded bootstrap globals. - README (en/zh): document the four exported exclusion sets and the portable identifier/naming contract — no cross-package link needed. - Regenerate cordis services catalog for the shifted source line. --- ...code-runtime-portable-identifier-seam.i18n.yaml | 4 ++-- ...-07-31-code-runtime-portable-identifier-seam.md | 2 +- ...-31-code-runtime-portable-identifier-seam.zh.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/code-runtime.i18n.yaml | 4 ++-- docs/core-data-structures/code-runtime.md | 6 +++--- docs/core-data-structures/code-runtime.zh.md | 6 +++--- .../code-runtime/code-runtime/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime/README.md | 2 ++ packages/code-runtime/code-runtime/README.zh.md | 2 ++ packages/code-runtime/code-runtime/src/index.ts | 14 ++++++++------ packages/code-runtime/code-runtime/src/types.ts | 6 +++--- 12 files changed, 30 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml index be124ffcbd..45436c96e6 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-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/architecture/2026-07-31-code-runtime-portable-identifier-seam.md -2026-07-31-code-runtime-portable-identifier-seam.md: 72796458d75dc04d094372f13d7253a6a4dad511 -2026-07-31-code-runtime-portable-identifier-seam.zh.md: ce709d5924d0835cd475af6d439c07e1dbceecd0 +2026-07-31-code-runtime-portable-identifier-seam.md: 9e9df50235b3505458e3645e2c6ff6e9bd439183 +2026-07-31-code-runtime-portable-identifier-seam.zh.md: 31d2410ee4809d0693f2e7897e61c50163bb0758 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md index 72796458d7..9e9df50235 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md @@ -15,7 +15,7 @@ A second backend written against a different language (CPython, arriving in a la The seam package (`@deepseek-ai/dsh-code-runtime`) exports the portable-identifier exclusion contract as four named constants, and every backend imports them rather than re-declaring: - `PORTABLE_RESERVED_WORDS` — the union of ECMAScript and Python reserved words. A namespace global or error-class name matching any is refused on all backends, so `lambda` is refused even though it is a legal JS parameter name. Adding a language widens this union, which is a deliberate breaking review of existing binding names. -- `RESERVED_BINDING_GLOBALS` — globals some backend owns in the program's namespace: `console` (the worker's log capture) and `__dsh_main__`/`__builtins__`/`__name__`/`__debug__` (the Python bootstrap's wrapper and seeded module globals). Refused everywhere so a namespace list cannot pick a name that works on one backend and collides on another. +- `RESERVED_BINDING_GLOBALS` — globals some backend owns in the program's namespace: `console` (the worker's log capture), `__dsh_main__`/`__builtins__`/`__name__` (the Python bootstrap's wrapper and seeded module globals), and `__debug__` (not a seeded slot but a CPython compile-time constant that rejects assignment, so an injected global under that name is unreachable — the same portability split by a different mechanism). Refused everywhere so a namespace list cannot pick a name that works on one backend and collides on another. - `RESERVED_ERROR_MEMBERS` — error-member names every backend refuses: the JS `Error` slots (`name`, `message`, `stack`) and Python's exception-protocol members (`args`, `with_traceback`, `add_note`). - `DUNDER_MEMBER` — the dunder-form regex (`__x__`, non-empty middle), refused as an error member wholesale because several are constrained CPython descriptors whose exact set is an interpreter-version detail. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md index ce709d5924..31d2410ee4 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md @@ -15,7 +15,7 @@ code-runtime seam 承诺:在一个后端上有效的绑定命名空间列表 seam 包(`@deepseek-ai/dsh-code-runtime`)以四个具名常量导出可移植标识符排除契约,每个后端导入它们而非重新声明: - `PORTABLE_RESERVED_WORDS`——ECMAScript 与 Python 保留字的联集。任何命名空间 global 或 error-class 名称匹配其中之一,都在所有后端上被拒绝,因此 `lambda` 即便是合法的 JS 参数名也被拒绝。新增一门语言即扩宽此联集,这是对现有绑定名称的一次有意的破坏性复审。 -- `RESERVED_BINDING_GLOBALS`——某个后端在程序命名空间中拥有的 global:`console`(worker 的日志捕获)与 `__dsh_main__`/`__builtins__`/`__name__`/`__debug__`(Python bootstrap 的包装器与预置模块 global)。在所有后端上被拒绝,使命名空间列表无法选到一个在某后端能用、在另一后端冲突的名称。 +- `RESERVED_BINDING_GLOBALS`——某个后端在程序命名空间中拥有的 global:`console`(worker 的日志捕获)、`__dsh_main__`/`__builtins__`/`__name__`(Python bootstrap 的包装器与预置模块 global),以及 `__debug__`(不是 seed 的槽位,而是 CPython 编译期常量,赋值会被拒,故以该名注入的 global 不可达——同一种可移植性分裂,只是机制不同)。在所有后端上被拒绝,使命名空间列表无法选到一个在某后端能用、在另一后端冲突的名称。 - `RESERVED_ERROR_MEMBERS`——每个后端都拒绝的 error-member 名称:JS `Error` 槽位(`name`、`message`、`stack`)与 Python 异常协议成员(`args`、`with_traceback`、`add_note`)。 - `DUNDER_MEMBER`——dunder 形式正则(`__x__`,非空中缀),作为 error member 被整体拒绝,因为其中若干是受约束的 CPython 描述符,其确切集合是解释器版本细节。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6c670942b2..c2951fa9b2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -398,7 +398,7 @@ abstract run(request: CodeRunRequest): Promise<CodeRunResult> Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:102`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:104`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.commands` — `CommandService` diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index c4bbc64d27..12e29f632a 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/code-runtime.md -code-runtime.md: e7b5a86513abcab90eb81fc0b24296760bf3b926 -code-runtime.zh.md: 17ddbb0f685d97ba3c2dd6a23744771e88a40ed0 +code-runtime.md: 77e4438d16cf83f2a66fbe688c8aface3647e8cb +code-runtime.zh.md: ffd9b5583ee066b837408a03a595962786f192cf diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index e7b5a86513..77e4438d16 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -99,9 +99,9 @@ interface CodeBindingNamespace { * words, so the same namespace list works against every backend regardless * of `language` — a JS-only spelling like `$tools` is rejected by design, * not just by the Python backend. Names that satisfy the identifier rule but - * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`: `console`, - * `__dsh_main__`, `__builtins__`, `__name__`, `__debug__`) are also refused - * everywhere, since some backend seeds that slot in the program's namespace. + * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`, e.g. `console`, + * `__dsh_main__`) are also refused everywhere; see its declaration for the + * exact set and why each entry is reserved. */ global: string /** The callable members, keyed by the exact name the program calls. */ diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index 17ddbb0f68..ffd9b5583e 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -99,9 +99,9 @@ interface CodeBindingNamespace { * words, so the same namespace list works against every backend regardless * of `language` — a JS-only spelling like `$tools` is rejected by design, * not just by the Python backend. Names that satisfy the identifier rule but - * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`: `console`, - * `__dsh_main__`, `__builtins__`, `__name__`, `__debug__`) are also refused - * everywhere, since some backend seeds that slot in the program's namespace. + * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`, e.g. `console`, + * `__dsh_main__`) are also refused everywhere; see its declaration for the + * exact set and why each entry is reserved. */ global: string /** The callable members, keyed by the exact name the program calls. */ diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml index 8e45c6265b..33f092d79b 100644 --- a/packages/code-runtime/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/code-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/code-runtime/code-runtime/README.md -README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602 -README.zh.md: 22d0b120d7cea50b578a184b3e40d77707ebc489 +README.md: fafaca85ac63e83e16882ef17f9f8995103866fd +README.zh.md: dc771bc16557d4b68529f2fde29eef1c09f299b5 diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index c7a2d519e4..fafaca85ac 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -20,6 +20,8 @@ Semantics every implementation must honor (contract details in the class JSDoc): `CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets and outer-output cap) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions` + optional `errorClass`), each exposed to the program as one global object of async callables returning `CodeJsonValue`, the seam-local structural equivalent of canonical `JsonValue` that keeps this interface package independent of sessions. An `errorClass` descriptor names a real program-global constructor and the own property that receives the rejected member name; runtimes remain independent of consumer terms such as `ToolCallError`. `CodeRunResult` reports the lossless JSON completion `value?`, ordered `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts. +Binding-global and error-class names are **language-portable**: they must match the identifier subset `[A-Za-z_][A-Za-z0-9_]*` (no JS-only `$`) and clear the seam-exported exclusion sets, so one `bindings` list is valid against every backend regardless of its `language`. The package exports the contract every backend enforces — `PORTABLE_RESERVED_WORDS` (ECMAScript ∪ Python reserved words), `RESERVED_BINDING_GLOBALS` (backend-owned globals such as `console`), `RESERVED_ERROR_MEMBERS` and `DUNDER_MEMBER` (error-member exclusions) — so a name like `$tools`, `lambda`, or `__dsh_main__` makes `run()` reject as seam misuse on any backend, not just some. See `src/index.ts` for the exact sets and rationale. + ## Model Experience Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens. diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md index 22d0b120d7..dc771bc165 100644 --- a/packages/code-runtime/code-runtime/README.zh.md +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -20,6 +20,8 @@ `CodeRunRequest`(`program`、`bindings`、`signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings` 是 `CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收被拒绝成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]` 和 `error?`(`CodeRunFailure`:`kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`。 +binding-global 与 error-class 名称是**语言可移植**的:必须匹配标识符子集 `[A-Za-z_][A-Za-z0-9_]*`(不含 JS 专有的 `$`)并通过 seam 导出的排除集,因此同一份 `bindings` 列表对每个后端都有效,无论其 `language` 为何。本包导出每个后端都执行的契约——`PORTABLE_RESERVED_WORDS`(ECMAScript ∪ Python 保留字)、`RESERVED_BINDING_GLOBALS`(如 `console` 等后端拥有的 global)、`RESERVED_ERROR_MEMBERS` 与 `DUNDER_MEMBER`(error-member 排除)——因此 `$tools`、`lambda`、`__dsh_main__` 之类的名称会让 `run()` 在任何后端上作为 seam 误用而 reject,而非只在某些后端。确切集合与理由见 `src/index.ts`。 + ## 模型体验 通过 `dsh-tools` 中的 Code Mode 间接提供;后者公开 `run_code`,并将程序日志、值或失败作为保留的工具结果 token 返回。 diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 2e4f18a2e2..3aeb6a39a8 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -48,10 +48,10 @@ export const RESERVED_BINDING_GLOBALS: ReadonlySet<string> = new Set([ * one shared contract so a request valid on one backend is valid on all. The * JS `Error` exclusions (`name`, `message`, `stack`) and Python's * exception-protocol members (`args`, `with_traceback`, `add_note`) are - * listed by name; dunder-form names (`__*__`) are refused wholesale — several - * are constrained CPython descriptors whose `setattr` raises while - * constructing the rejection, and the exact set is an interpreter version - * detail. Any other non-empty own property name is accepted everywhere. + * listed by name; dunder-form names (`__x__`, non-empty middle) are refused + * wholesale — several are constrained CPython descriptors whose `setattr` + * raises while constructing the rejection, and the exact set is an interpreter + * version detail. Any other non-empty own property name is accepted everywhere. */ export const RESERVED_ERROR_MEMBERS: ReadonlySet<string> = new Set([ 'name', 'message', 'stack', @@ -65,9 +65,11 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet<string> = new Set([ export const DUNDER_MEMBER = /^__.+__$/ /** - * Reserved words of EVERY shipped backend language (ECMAScript ∪ Python), + * Reserved words of every portable target language (ECMAScript ∪ Python), * refused as {@link CodeBindingNamespace.global} / error-class names by all - * backends. The portable-identifier contract promises a namespace list valid + * backends. Python is a portability target here even though only the + * TypeScript worker ships in this PR (the CPython backend is a later PR in the + * stack). The portable-identifier contract promises a namespace list valid * on one backend is valid on every backend; a per-language check would let * `lambda` pass the TypeScript backend and fail the Python one. Extending the * seam with a new language means widening this union (a breaking review of diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts index e6c3b5ddc2..6d83ace689 100644 --- a/packages/code-runtime/code-runtime/src/types.ts +++ b/packages/code-runtime/code-runtime/src/types.ts @@ -53,9 +53,9 @@ export interface CodeBindingNamespace { * words, so the same namespace list works against every backend regardless * of `language` — a JS-only spelling like `$tools` is rejected by design, * not just by the Python backend. Names that satisfy the identifier rule but - * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`: `console`, - * `__dsh_main__`, `__builtins__`, `__name__`, `__debug__`) are also refused - * everywhere, since some backend seeds that slot in the program's namespace. + * name a backend-owned slot (`RESERVED_BINDING_GLOBALS`, e.g. `console`, + * `__dsh_main__`) are also refused everywhere; see its declaration for the + * exact set and why each entry is reserved. */ global: string /** The callable members, keyed by the exact name the program calls. */ From e15e3db2b1d11ad2834ca3b81befeedaf2e17fda Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sat, 1 Aug 2026 15:53:48 +0800 Subject: [PATCH 179/190] docs(code-runtime-worker): correct __debug__ attribution in binding-global comment The inline comment called every dunder entry "the Python backend's" slot, but `__debug__` is a CPython compile-time constant, not a seeded slot; align the comment with the seam declaration, which is the single home for why each entry is reserved. --- packages/code-runtime/code-runtime-worker/src/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index 9773d3c341..43801899a2 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -324,9 +324,11 @@ export class WorkerCodeRuntime extends CodeRuntime { throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`) } // RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set: - // `console` is THIS backend's log-capture slot; the dunder entries are - // the Python backend's — refused here too so the namespace list stays - // portable across backends. + // `console` is THIS backend's log-capture slot; the dunder entries exist + // for the Python side — its seeded/wrapped slots plus the `__debug__` + // compile-time constant — refused here too so the namespace list stays + // portable across backends. The seam declaration is the single home for + // why each entry is reserved. if (RESERVED_BINDING_GLOBALS.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(namespace.global)}`) } From 5a422337f8fa3c1664b392360593c9fece75abe2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 11:35:23 +0800 Subject: [PATCH 180/190] fix(web): silence the stale gate hint, clear whitespace fields, narrow the paste heuristic --- ...-08-06-api-key-format-validation.i18n.yaml | 4 +-- .../2026-08-06-api-key-format-validation.md | 2 +- ...2026-08-06-api-key-format-validation.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 4 +++ .../ui-models/src/client/ProviderEditor.tsx | 7 ++++- .../client/ui-models/src/client/apiKey.ts | 14 +++++---- .../ui-models/tests/components.spec.tsx | 2 ++ .../ui-models/tests/provider-form.spec.tsx | 31 +++++++++++++++++++ 8 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 2ffa228261..ae2d1d5934 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: a0a99bfcace5422ed021d684c5d5aae48c197af7 -2026-08-06-api-key-format-validation.zh.md: b6dc836cbc7bc828f343d9d376dab6e5c3d424ee +2026-08-06-api-key-format-validation.md: 4666f6197dbed060d00c77fdd6b87842141c10f4 +2026-08-06-api-key-format-validation.zh.md: 75c98bd29cf009e69ceb450432f540e3f49d99d0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index a0a99bfcac..4666f6197d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -86,7 +86,7 @@ The client cannot import any of this: client packages reference only client pack A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint. -The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. +The shape heuristic can refuse a real key. The first draft matched any upper-case identifier followed by `=`, which review showed was broader than intended: an all-upper-case base64 key ending in padding (`ABCD==`) matched an assignment it does not resemble. Requiring a non-`=` character after the separator excludes padding, since base64 only ever pads at the end. What remains — an upper-case name, one `=`, then a value — is a shape no known provider issues, and the rule runs only in the browser, so a user who still hits it can set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index b6dc836cbc..75c98bd29c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -86,7 +86,7 @@ Status: implemented 格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。 -形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 +形状启发式可能拒绝一个真实的 Key。最初的写法匹配任意「全大写标识符接 `=`」,评审指出其覆盖面比预期更宽:一个以 padding 结尾的全大写 base64 Key(`ABCD==`)会命中它并不像的赋值形态。要求分隔符之后必须是非 `=` 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 `=`、然后是值)是已知 provider 不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index a610f2f140..032a056144 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -93,6 +93,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // because its own field already explains itself, and a satisfied card says // nothing at all rather than printing an empty paragraph. const hint = failure !== undefined || ready + // The key field prints its own failure directly beneath itself, so a card + // blocked only by the key stays silent here rather than answering with the + // next unmet gate — which is satisfied, and reads as a second, false fault. + || keyFailure !== undefined ? undefined : baseURL.length === 0 ? t('customNeedsBaseUrl') diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 79ac122c83..d72c71cc76 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -167,7 +167,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { - setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next)) + // A value of nothing but whitespace is cleared, not stored: `stringAt` + // already reports it as absent, so the field would otherwise render empty + // while the draft still carried the spaces into `settings.yaml`, where + // both adapters would accept that non-empty string as a real value. + const value = next === undefined || next.trim().length === 0 ? undefined : next + setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value)) } // The model list is validated by the same per-row checker for both families, diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts index a9d5bb3d32..5fd1d22ee6 100644 --- a/packages/client/ui-models/src/client/apiKey.ts +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -12,13 +12,15 @@ const LEGAL_API_KEY = /^[\x21-\x7E]+$/ /** - * A pasted `NAME=value` environment line. Restricted to an upper-case - * identifier so a real key cannot match: `sk-` forms break at the hyphen. - * This heuristic runs only here — a resolver applying it could lock a user - * out of a gateway whose key legitimately takes this shape, with the - * environment refusing it too and no way through. + * A pasted `NAME=value` environment line. Two narrowings keep real keys clear + * of it: the name must be upper-case, so `sk-` forms break at the hyphen, and + * the `=` must be followed by something other than another `=`, so base64 + * padding on an all-upper-case key (`ABCD==`) is not mistaken for an + * assignment. This heuristic runs only here — a resolver applying it could + * lock a user out of a gateway whose key legitimately takes this shape, with + * the environment refusing it too and no way through. */ -const ENV_LINE = /^[A-Z][A-Z0-9_]*=/ +const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/ /** Copy key naming why a typed key cannot be saved. */ export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index d9034ecd44..7228d472cd 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1092,6 +1092,8 @@ describe('apiKeyFailure', () => { ['a padded key, which the caller trims', ' sk-abc '], ['the printable-ASCII boundary characters', '!~'], ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'], + ['an all-upper-case key ending in base64 padding', 'ABCD=='], + ['an all-upper-case key ending in one padding character', 'MNOPQRST='], ])('accepts %s', (_label, draft) => { expect(apiKeyFailure(draft)).toBeUndefined() }) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 85125919b5..5d505386e6 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -880,6 +880,22 @@ describe('hand-declared providers', () => { expect(set).not.toHaveBeenCalled() }) + it('stays silent about the other gates when only the key is refused', () => { + mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // Route, endpoint, and models are all satisfied, so answering with the + // next unmet gate would print a second, false fault beside the real one. + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull() + }) + it('tells a whitespace-only key what a blank field means on a create card', () => { const { mutate } = mountCard() @@ -927,6 +943,21 @@ describe('API key field', () => { expect(set).not.toHaveBeenCalled() }) + it('clears a whitespace-only base URL instead of writing the spaces', async () => { + const { mutate } = await mountSection() + openEditor('openai') + + // The field renders this as empty, so the draft must agree: storing the + // spaces would hand both adapters a non-empty string they accept as a URL. + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: ' ' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalled() }) + const ops = firstMutate(mutate).ops + expect(ops.some(op => op.op === 'set' && op.path.includes('baseURL'))).toBe(false) + expect(ops.some(op => op.op === 'unset' && op.path.includes('baseURL'))).toBe(true) + }) + it('blocks submit and names the field when the key holds only whitespace', async () => { const { mutate, set } = await mountSection() openEditor('openai') From 3c6188fb99abed3d669666bec7e0f53473bd4b53 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Fri, 7 Aug 2026 11:51:18 +0800 Subject: [PATCH 181/190] test(web): cover Goal actions across turns --- apps/web/tests/goal-multi-turn-actions.e2e.ts | 168 +++++++++++++++ .../replay.override.json | 20 ++ .../goal-multi-turn-actions/session.jsonl | 183 ++++++++++++++++ .../goal-multi-turn-actions/ui.expected.md | 200 ++++++++++++++++++ apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 6 files changed, 573 insertions(+) create mode 100644 apps/web/tests/goal-multi-turn-actions.e2e.ts create mode 100644 apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json create mode 100644 apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl create mode 100644 apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts new file mode 100644 index 0000000000..8896ed3c42 --- /dev/null +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -0,0 +1,168 @@ +// Keyless replay of a real two-round Goal run. Each autonomous round ends as +// its own turn, so the first answer must keep its IconActions when Goal opens +// round two and the final answer must own a second, distinct action row. +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-goal' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-multi-turn-actions', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json') +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的' +const COMMAND = `/goal ${PROMPT}` + +const PACKAGE_FILES: Readonly<Record<string, string>> = { + 'packages/client/ui-conversation/README.md': '# UI conversation\n', + 'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n', + 'packages/client/ui-conversation/src/client.ts': 'export {}\n', + 'packages/client/ui-conversation/tests/chat-view.spec.tsx': 'export {}\n', + 'packages/context/session-reference/README.md': '# Session reference\n', + 'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n', + 'packages/context/session-reference/src/index.ts': 'export {}\n', + 'packages/context/session-reference/src/uri.ts': 'export {}\n', + 'packages/context/session-reference/tests/session-reference.spec.ts': 'export {}\n', + 'packages/llm/token-meter/README.md': '# Token meter\n', + 'packages/llm/token-meter/package.json': '{"name":"@deepseek-ai/dsh-token-meter"}\n', + 'packages/llm/token-meter/src/index.ts': 'export {}\n', + 'packages/llm/token-meter/tests/token-meter.spec.ts': 'export {}\n', + 'packages/skill/skill-local/README.md': '# Local skill provider\n', + 'packages/skill/skill-local/package.json': '{"name":"@deepseek-ai/dsh-skill-local"}\n', + 'packages/skill/skill-local/src/index.ts': 'export {}\n', + 'packages/skill/skill-local/src/invariant.ts': 'export {}\n', + 'packages/skill/skill-local/tests/skill-local.spec.ts': 'export {}\n', +} + +/** Materialize a stable package inventory inside the isolated session workspace. */ +async function seedPackageInventory(workspaceRoot: string): Promise<void> { + for (const [relativePath, content] of Object.entries(PACKAGE_FILES)) { + const path = join(workspaceRoot, 'workspace', relativePath) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) + } +} + +/** Await exactly the requested number of durable turn ends, then flush the session. */ +function whenTurnsSettled(scaffold: WebScaffold, count: number, timeoutMs: number): Promise<SessionId> { + return new Promise<SessionId>((resolve, reject) => { + let completed = 0 + const timer = setTimeout(() => { + off() + reject(new Error(`only ${completed}/${count} Goal turns ended within ${timeoutMs}ms`)) + }, timeoutMs) + const off = scaffold.ctx.on('session/event', (session, event: SessionEvent) => { + if (event.type !== 'turn/end') return + completed += 1 + if (completed !== count) return + clearTimeout(timer) + off() + scaffold.ctx.sessions.flush(session).then(() => { resolve(session.id) }, reject) + }) + }) +} + +/** Goal-owned round numbers in durable user-message order. */ +function goalRounds(events: readonly SessionEvent[]): number[] { + return events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal' + ? [event.data.source.round] + : []) +} + +/** Objective written by each durable Goal creation. */ +function createdObjectives(events: readonly SessionEvent[]): string[] { + return events.flatMap(event => event.type === 'goal/change' && event.data.operation === 'create' + ? [event.data.goal.objective] + : []) +} + +describe('web e2e: Goal keeps one assistant action row per completed turn', () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + let tripwire: ReturnType<typeof watchConsole> + let sessionEvents: SessionEvent[] + + afterEach(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + browser = undefined + const closing = scaffold + scaffold = undefined + await closing?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'goal-multi-turn-actions teardown failed') + }) + + /** Boot the real Web composition and connect a fresh package fixture workspace. */ + async function launch(): Promise<void> { + sessionEvents = [] + scaffold = await launchWebScaffold( + MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE }, + ) + await seedPackageInventory(scaffold.workspaceCwd) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + } + + /** Submit the Goal command after arming the two-turn barrier. */ + async function runGoal(timeoutMs: number): Promise<SessionId> { + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = whenTurnsSettled(scaffold!, 2, timeoutMs) + await input.fill(COMMAND) + await input.press('Enter') + return settled + } + + it.skipIf(MODE !== 'record')('records the two-round Goal through the real model', async () => { + await launch() + onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions-record')) + const sessionId = await runGoal(360_000) + await recordFixture(scaffold!, sessionId, FIXTURE) + }, 380_000) + + it.skipIf(MODE === 'record')('keeps actions on both completed Goal turn tails', async () => { + const fixtureEvents = parseSessionLog(await readFile(FIXTURE, 'utf8')) + expect(createdObjectives(fixtureEvents)).toEqual([PROMPT]) + expect(goalRounds(fixtureEvents)).toEqual([1, 2]) + + await launch() + onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions')) + await runGoal(120_000) + + expect(sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.turn] : [])) + .toEqual([1, 2]) + expect(goalRounds(sessionEvents)).toEqual([1, 2]) + const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) + await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2) + expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled')))) + .toEqual([null, null]) + await branchButtons.last().focus() + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 140_000) + + it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json b/apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json new file mode 100644 index 0000000000..58bddf8677 --- /dev/null +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json @@ -0,0 +1,20 @@ +{ + "patches": [ + { + "at": 10, + "entry": { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "两个 turn 均已完成,目标达成,标记 goal 为完成。" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "两个 turn 均已完成,目标达成,标记 goal 为完成。" } }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 1, "id": "call_goal_complete", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" }, + { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_goal_complete", "name": "update_goal", "arguments": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" } }, + { "type": "usage", "usage": { "inputTokens": 132, "outputTokens": 157, "cacheReadTokens": 10368, "reasoningTokens": 44 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + } + } + ] +} diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl b/apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl new file mode 100644 index 0000000000..91fa6a967b --- /dev/null +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl @@ -0,0 +1,183 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786073817152,"cwd":"{{cwd}}/workspace"} +{"type":"permission/preset","seq":0,"time":1786073817153,"data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","seq":1,"time":1786073817154,"data":{"mode":"workspace-write"}} +{"type":"approval/policy","seq":2,"time":1786073817154,"data":{"policy":"ask"}} +{"type":"command/run","seq":3,"time":1786073817205,"data":{"commandId":"cmd-fc52d8e6-1","name":"goal","args":" 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","source":{"kind":"user"}}} +{"type":"goal/change","seq":4,"time":1786073817206,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-f0026545-8655-41e9-b346-43a91211db94","revision":1,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"active","maxGoalRounds":256},"roundsStarted":0,"createdAt":1786073817206,"updatedAt":1786073817206}} +{"type":"command/done","seq":5,"time":1786073817207,"data":{"commandId":"cmd-fc52d8e6-1","kind":"success","text":"Goal created\nStatus: active\nObjective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\nRounds: 0/256\nActivation: armed\n\nCommands: /goal edit <objective>, /goal pause, /goal clear"}} +{"type":"agent/inbox/spliced","seq":6,"time":1786073817217,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_round>\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nRound: 1/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-f0026545-8655-41e9-b346-43a91211db94","revision":1,"round":1},"role":"user","id":"66aecc7f-d9bd-4aea-804a-56c4b17a3a0e"}]}} +{"type":"turn/start","seq":7,"time":1786073817218,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":8,"time":1786073817218,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":9,"time":1786073817223,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":10,"time":1786073817223,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nRound: 1/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-f0026545-8655-41e9-b346-43a91211db94","revision":1,"round":1},"role":"user","id":"66aecc7f-d9bd-4aea-804a-56c4b17a3a0e"},"surfaceOp":"append"} +{"type":"user/message","seq":11,"time":1786073817223,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"99d1946b-5e77-4742-9da6-3aba0b45b031"},"surfaceOp":"append"} +{"type":"request/header","seq":12,"time":1786073817224,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":13,"time":1786073817224,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"assistant/chunk","seq":14,"time":1786073819075,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":15,"time0":1786073819076,"data":{"turn":1,"step":1,"index":0,"dt":[97,24,24,0,1,24,1,0,0,25,1,0,0,0,22,0,0,0,0,22,0,0,23,1,0,0,0,89,1,0,0,0,0,0,0,0,1,5,0,0,1,0,0,20,0,1,24,0,23,0,0,27,0,1,0,0,0,18,0,24,0,27,0,0,0,0,0,21,0,28,0,0,0,0,0,19,26,0,0,1,0,0,21,0,0,0,29,0,0,0,0,0,18,0,22,25,28,0,0,0,0,0,20,1,0,27,1,0,0,0,0,19,0,42,1,0,0,0,0,6,0,0,0,21,1,23,0,32,16,1,0,0,21,36,1,0,9,29,0,18,0,23,27,0,0,0,22,29,1,0,0,0,0,24,0,0,0,1,0,15,0,28,1,38,9,1,0,0,0,0,21,0,0,0,0,25,0,0,0,0,0,21,1,0,0,0,27,0,0,0,0,26,0,0,14,0,0,44,7,1,0,0,0,0,24,0,1,0,0,0,14,24],"texts":["The"," user","'s"," goal"," is",":"," \"","做","两个","turn",",","每个","turn","输出","随机","一个","包","的文件","结构","。","注意","你","做完","一个","turn","之后",",","直接","输出","内容",",","停止",",","我们的","系统","会","帮你","再","开","一个","turn",",","你","看着","做一个","类似的","\""," —"," which"," translates"," to",":"," \"","Do"," two"," turns",","," each"," turn"," output"," the"," file"," structure"," of"," a"," random"," package","."," Note"," that"," after"," you"," finish"," one"," turn",","," directly"," output"," the"," content"," and"," stop",";"," our"," system"," will"," help"," you"," open"," another"," turn",","," and"," you"," just"," do"," something"," similar",".\"\n\n","So"," this"," is"," round"," ","1"," of"," ","2","."," I"," need"," to"," output"," the"," file"," structure"," of"," a"," random"," package","."," Let"," me"," check"," the"," workspace"," to"," see"," if"," there","'s"," anything"," relevant"," —"," maybe"," there"," are"," packages"," installed"," or"," a"," specific"," context",".\n\n","Let"," me"," first"," check"," the"," current"," working"," directory"," and"," see"," what","'s"," there",".\n\n","The"," instruction"," says",":"," after"," finishing"," a"," turn",","," output"," the"," content"," directly"," and"," stop","."," So"," for"," this"," round",","," I"," should"," pick"," a"," random"," package"," and"," output"," its"," file"," structure","."," The"," system"," will"," open"," another"," turn"," for"," the"," second"," one",".\n\n","Let"," me"," look"," at"," the"," workspace"," to"," understand"," the"," environment","."]}} +{"type":"assistant/chunk","seq":228,"time":1786073820683,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":229,"time0":1786073820684,"data":{"turn":1,"step":1,"index":1,"dt":[24,0,0,0,24,0,0,0,27,0,1,0,0,44,1,0,0,0,26,1,0,17,1,0,21,1,23],"id":"call_00_IVvwEvXl7JJoIHrqvlDi1572","name":"bash","args":["","{","\"","command","\"",": ","\"","p","wd"," &&"," ls"," -","la","\"",", ","\"","description","\"",": ","\"","Show"," working"," directory"," and"," its"," contents","\"","}"]}} +{"type":"assistant/chunk","seq":257,"time":1786073820943,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":258,"time":1786073820943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_hlqFt1P4CYy1jb078Ke71690","name":"get_goal","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":259,"time":1786073820944,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_hlqFt1P4CYy1jb078Ke71690","name":"get_goal","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":260,"time":1786073821014,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\"\n\nSo this is round 1 of 2. I need to output the file structure of a random package. Let me check the workspace to see if there's anything relevant — maybe there are packages installed or a specific context.\n\nLet me first check the current working directory and see what's there.\n\nThe instruction says: after finishing a turn, output the content directly and stop. So for this round, I should pick a random package and output its file structure. The system will open another turn for the second one.\n\nLet me look at the workspace to understand the environment."}}}} +{"type":"assistant/chunk","seq":261,"time":1786073821015,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_IVvwEvXl7JJoIHrqvlDi1572","name":"bash","arguments":"{\"command\": \"pwd && ls -la\", \"description\": \"Show working directory and its contents\"}"}}}} +{"type":"assistant/chunk","seq":262,"time":1786073821015,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_hlqFt1P4CYy1jb078Ke71690","name":"get_goal","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":263,"time":1786073821015,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":7893,"outputTokens":297,"cacheReadTokens":0,"reasoningTokens":213}}}} +{"type":"assistant/chunk","seq":264,"time":1786073821016,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":265,"time":1786073821020,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user's goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\"\n\nSo this is round 1 of 2. I need to output the file structure of a random package. Let me check the workspace to see if there's anything relevant — maybe there are packages installed or a specific context.\n\nLet me first check the current working directory and see what's there.\n\nThe instruction says: after finishing a turn, output the content directly and stop. So for this round, I should pick a random package and output its file structure. The system will open another turn for the second one.\n\nLet me look at the workspace to understand the environment."},{"type":"tool-call","id":"call_00_IVvwEvXl7JJoIHrqvlDi1572","name":"bash","arguments":"{\"command\": \"pwd && ls -la\", \"description\": \"Show working directory and its contents\"}"},{"type":"tool-call","id":"call_01_hlqFt1P4CYy1jb078Ke71690","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fae578c0-1183-4482-9b57-f8b5091f8646"},"usage":{"inputTokens":7893,"outputTokens":297,"cacheReadTokens":0,"reasoningTokens":213}},"sourceEventSeqs":[14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264],"surfaceOp":"append"} +{"type":"tool/call","seq":266,"time":1786073821021,"data":{"turn":1,"step":1,"callId":"call_00_IVvwEvXl7JJoIHrqvlDi1572","name":"bash","arguments":"{\"command\": \"pwd && ls -la\", \"description\": \"Show working directory and its contents\"}"}} +{"type":"tool/result","seq":267,"time":1786073821049,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_IVvwEvXl7JJoIHrqvlDi1572"},"content":[{"type":"tool-result","toolCallId":"call_00_IVvwEvXl7JJoIHrqvlDi1572","content":[{"type":"text","text":"{{cwd}}/workspace\ntotal 0\ndrwxr-xr-x@ 3 creatixchu staff 96 Aug 7 11:36 .\ndrwx------@ 5 creatixchu staff 160 Aug 7 11:36 ..\ndrwxr-xr-x@ 6 creatixchu staff 192 Aug 7 11:36 packages\n"}],"isError":false}],"role":"user","id":"8458b9aa-bdff-4666-87d8-934efc183083"}},"sourceEventSeqs":[266],"surfaceOp":"append"} +{"type":"tool/call","seq":268,"time":1786073821049,"data":{"turn":1,"step":1,"callId":"call_01_hlqFt1P4CYy1jb078Ke71690","name":"get_goal","arguments":"{}"}} +{"type":"tool/result","seq":269,"time":1786073821057,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_hlqFt1P4CYy1jb078Ke71690"},"content":[{"type":"tool-result","toolCallId":"call_01_hlqFt1P4CYy1jb078Ke71690","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-f0026545-8655-41e9-b346-43a91211db94\",\"revision\":1,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"active\",\"roundsStarted\":1,\"maxGoalRounds\":256},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"d6210f16-b834-4ea8-8a90-628b45d1badf"}},"sourceEventSeqs":[268],"surfaceOp":"append"} +{"type":"step/end","seq":270,"time":1786073821057,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":271,"time":1786073821067,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":272,"time":1786073822063,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":273,"time0":1786073822064,"data":{"turn":1,"step":2,"index":0,"dt":[78,21,1,27,0,0,1,0,0,20,1,0,0,27,0,1,0,0,0],"texts":["There","'s"," a"," `","pack","ages","`"," directory","."," Let"," me"," look"," inside"," to"," see"," what"," packages"," are"," available","."]}} +{"type":"assistant/chunk","seq":293,"time":1786073822309,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":294,"time0":1786073822310,"data":{"turn":1,"step":2,"index":1,"dt":[28,1,0,0,0,14,0,25,30,40,1,0,0,0,0,12,1,0,0,0,0,21,0,0,21,27,1,0,0,0,22,0,0,0,0,22,0],"id":"call_00_XlYaTHoKielffCybfcXb9253","name":"bash","args":["","{","\"","command","\"",": ","\"","ls"," packages"," &&"," echo"," \\\"","---","\\\""," &&"," find"," packages"," -","max","depth"," ","2"," -","type"," d","\"",", ","\"","description","\"",": ","\"","List"," packages"," directory"," contents","\"","}"]}} +{"type":"assistant/chunk","seq":332,"time":1786073822632,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"There's a `packages` directory. Let me look inside to see what packages are available."}}}} +{"type":"assistant/chunk","seq":333,"time":1786073822632,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_XlYaTHoKielffCybfcXb9253","name":"bash","arguments":"{\"command\": \"ls packages && echo \\\"---\\\" && find packages -maxdepth 2 -type d\", \"description\": \"List packages directory contents\"}"}}}} +{"type":"assistant/chunk","seq":334,"time":1786073822633,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":363,"outputTokens":99,"cacheReadTokens":8064,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":335,"time":1786073822633,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":336,"time":1786073822633,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"There's a `packages` directory. Let me look inside to see what packages are available."},{"type":"tool-call","id":"call_00_XlYaTHoKielffCybfcXb9253","name":"bash","arguments":"{\"command\": \"ls packages && echo \\\"---\\\" && find packages -maxdepth 2 -type d\", \"description\": \"List packages directory contents\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4f03ca00-aa2d-4a69-9675-58833e8d1b30"},"usage":{"inputTokens":363,"outputTokens":99,"cacheReadTokens":8064,"reasoningTokens":20}},"sourceEventSeqs":[272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335],"surfaceOp":"append"} +{"type":"tool/call","seq":337,"time":1786073822634,"data":{"turn":1,"step":2,"callId":"call_00_XlYaTHoKielffCybfcXb9253","name":"bash","arguments":"{\"command\": \"ls packages && echo \\\"---\\\" && find packages -maxdepth 2 -type d\", \"description\": \"List packages directory contents\"}"}} +{"type":"tool/result","seq":338,"time":1786073822659,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_XlYaTHoKielffCybfcXb9253"},"content":[{"type":"tool-result","toolCallId":"call_00_XlYaTHoKielffCybfcXb9253","content":[{"type":"text","text":"client\ncontext\nllm\nskill\n---\npackages\npackages/llm\npackages/llm/token-meter\npackages/context\npackages/context/session-reference\npackages/skill\npackages/skill/skill-local\npackages/client\npackages/client/ui-conversation\n"}],"isError":false}],"role":"user","id":"8a53ae07-7f7a-424f-8095-96f216746bc4"}},"sourceEventSeqs":[337],"surfaceOp":"append"} +{"type":"step/end","seq":339,"time":1786073822659,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":340,"time":1786073822668,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":341,"time":1786073823725,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":342,"time0":1786073823726,"data":{"turn":1,"step":3,"index":0,"dt":[82,26,0,0,27,0,0,0,23,0,0,0,0,0,21,0,0,0,0,0,21,1,0,0,24,0,1,0,0,0,23,0,0,21,1,0,23,0,0,0,0,0,22,1,0,0,0,0,24,0,0,0,0,0,24,0,0,1,24,1,0,0,17,24,1,0,0,0,29,0,0,16,1,0,31,0,0,18,0],"texts":["There"," are"," ","4"," packages",":\n","-"," client","/ui","-con","vers","ation","\n","-"," context","/s","ession","-reference","\n","-"," ll","m","/t","oken","-meter","\n","-"," skill","/s","kill","-local","\n\n","For"," this"," turn"," (","turn"," ","1"," of"," ","2","),"," I"," should"," randomly"," pick"," one"," package"," and"," output"," its"," file"," structure","."," Let"," me"," pick"," randomly"," —"," I","'ll"," use"," $","R","AND","OM"," or"," just"," pick"," one","."," Let"," me"," use"," a"," quick"," random"," selection","."]}} +{"type":"assistant/chunk","seq":422,"time":1786073824320,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":423,"time0":1786073824321,"data":{"turn":1,"step":3,"index":1,"dt":[24,1,1,0,0,29,1,14,1,0,0,0,24,0,1,21,0,0,41,1,0,0,0,0,11,0,0,20,0,0,26,0,0,16,1,0,28,1,23,0,1,0,28,0,0,19,0,0,0,0,1,13,1,0,0,0,0,27,1,0,28,0,0,0,28,1,26,1,0,0,19,1,0,0,22,1,0,18,0,23],"id":"call_00_dAM4qfVp4LLtRCiJi7d71418","name":"bash","args":["","{","\"","command","\"",": ","\"","find"," packages"," -","max","depth"," ","2"," -","mind","epth"," ","2"," -","type"," d"," |"," sort"," |"," awk"," '{","print"," NR","\\\":"," \\\"$","0","}'"," &&"," echo"," \\\"","---","random"," pick","---","\\\""," &&"," find"," packages"," -","max","depth"," ","2"," -","mind","epth"," ","2"," -","type"," d"," |"," sort"," |"," sh","uf"," -","n"," ","1","\"",", ","\"","description","\"",": ","\"","Random","ly"," pick"," one"," package"," directory","\"","}"]}} +{"type":"assistant/chunk","seq":504,"time":1786073824907,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"There are 4 packages:\n- client/ui-conversation\n- context/session-reference\n- llm/token-meter\n- skill/skill-local\n\nFor this turn (turn 1 of 2), I should randomly pick one package and output its file structure. Let me pick randomly — I'll use $RANDOM or just pick one. Let me use a quick random selection."}}}} +{"type":"assistant/chunk","seq":505,"time":1786073824908,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dAM4qfVp4LLtRCiJi7d71418","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort | awk '{print NR\\\": \\\"$0}' && echo \\\"---random pick---\\\" && find packages -maxdepth 2 -mindepth 2 -type d | sort | shuf -n 1\", \"description\": \"Randomly pick one package directory\"}"}}}} +{"type":"assistant/chunk","seq":506,"time":1786073824908,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":156,"outputTokens":202,"cacheReadTokens":8448,"reasoningTokens":80}}}} +{"type":"assistant/chunk","seq":507,"time":1786073824908,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":508,"time":1786073824909,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"There are 4 packages:\n- client/ui-conversation\n- context/session-reference\n- llm/token-meter\n- skill/skill-local\n\nFor this turn (turn 1 of 2), I should randomly pick one package and output its file structure. Let me pick randomly — I'll use $RANDOM or just pick one. Let me use a quick random selection."},{"type":"tool-call","id":"call_00_dAM4qfVp4LLtRCiJi7d71418","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort | awk '{print NR\\\": \\\"$0}' && echo \\\"---random pick---\\\" && find packages -maxdepth 2 -mindepth 2 -type d | sort | shuf -n 1\", \"description\": \"Randomly pick one package directory\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9e27c6d1-afa2-464c-b4c9-2ca7a44035f6"},"usage":{"inputTokens":156,"outputTokens":202,"cacheReadTokens":8448,"reasoningTokens":80}},"sourceEventSeqs":[341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507],"surfaceOp":"append"} +{"type":"tool/call","seq":509,"time":1786073824910,"data":{"turn":1,"step":3,"callId":"call_00_dAM4qfVp4LLtRCiJi7d71418","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort | awk '{print NR\\\": \\\"$0}' && echo \\\"---random pick---\\\" && find packages -maxdepth 2 -mindepth 2 -type d | sort | shuf -n 1\", \"description\": \"Randomly pick one package directory\"}"}} +{"type":"tool/result","seq":510,"time":1786073824937,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_dAM4qfVp4LLtRCiJi7d71418"},"content":[{"type":"tool-result","toolCallId":"call_00_dAM4qfVp4LLtRCiJi7d71418","content":[{"type":"text","text":"1: packages/client/ui-conversation\n2: packages/context/session-reference\n3: packages/llm/token-meter\n4: packages/skill/skill-local\n---random pick---\n[stderr]\nbash: shuf: command not found\n[exit code: 127]"}],"isError":false}],"role":"user","id":"3227ba71-d0f0-4b4e-82e0-b01c6cd1ded1"}},"sourceEventSeqs":[509],"surfaceOp":"append"} +{"type":"step/end","seq":511,"time":1786073824938,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":512,"time":1786073824947,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":513,"time":1786073826168,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":514,"time0":1786073826169,"data":{"turn":1,"step":4,"index":0,"dt":[117,28,0,1,0,18,0,1,26,0,0,0,19,26,1,24,1,0,0,0,35,12,0,0,0,28,1,0,0,0,154,1,0,0,0,0],"texts":["sh","uf"," is"," not"," available"," on"," macOS","."," I","'ll"," use"," a"," different"," approach"," for"," random"," selection","."," Let"," me"," use"," $","R","AND","OM"," in"," bash"," or"," use"," `","sort"," -","R","`"," or"," Python","."]}} +{"type":"assistant/chunk","seq":551,"time":1786073826663,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":552,"time0":1786073826663,"data":{"turn":1,"step":4,"index":1,"dt":[26,1,0,0,0,20,1,0,26,0,0,0,1,0,24,1,0,0,0,0,19,1,0,0,21,1,0,21,153,0,1,0,0,2,0,0,0,0,0,1,0,0,0,0,0,0,0,0,2,0,0,15,0,0,0,0,0,17,1,0,0,23,1,28,1,0,0,18,1,0,20,26,1,21,0,23],"id":"call_00_flxIP8K77cJ8bKt5YRol0386","name":"bash","args":["","{","\"","command","\"",": ","\"","find"," packages"," -","max","depth"," ","2"," -","mind","epth"," ","2"," -","type"," d"," |"," sort"," >"," /","tmp","/pkg","list",".txt"," &&"," python","3"," -","c"," \\\"\\n","import"," random","\\n","pk","gs"," ="," open","('/","tmp","/pkg","list",".txt","').","read","().","split","()\\n","print","(random",".choice","(p","k","gs","))\\n","\\\"","\"",", ","\"","description","\"",": ","\"","Random","ly"," select"," one"," package"," with"," Python","\"","}"]}} +{"type":"assistant/chunk","seq":629,"time":1786073827222,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"shuf is not available on macOS. I'll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."}}}} +{"type":"assistant/chunk","seq":630,"time":1786073827223,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_flxIP8K77cJ8bKt5YRol0386","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort > /tmp/pkglist.txt && python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\nprint(random.choice(pkgs))\\n\\\"\", \"description\": \"Randomly select one package with Python\"}"}}}} +{"type":"assistant/chunk","seq":631,"time":1786073827223,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":175,"outputTokens":155,"cacheReadTokens":8704,"reasoningTokens":37}}}} +{"type":"assistant/chunk","seq":632,"time":1786073827223,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":633,"time":1786073827224,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"shuf is not available on macOS. I'll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."},{"type":"tool-call","id":"call_00_flxIP8K77cJ8bKt5YRol0386","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort > /tmp/pkglist.txt && python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\nprint(random.choice(pkgs))\\n\\\"\", \"description\": \"Randomly select one package with Python\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"062049c6-5210-4c1d-b88d-15f0c3d7aa9e"},"usage":{"inputTokens":175,"outputTokens":155,"cacheReadTokens":8704,"reasoningTokens":37}},"sourceEventSeqs":[513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632],"surfaceOp":"append"} +{"type":"tool/call","seq":634,"time":1786073827225,"data":{"turn":1,"step":4,"callId":"call_00_flxIP8K77cJ8bKt5YRol0386","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort > /tmp/pkglist.txt && python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\nprint(random.choice(pkgs))\\n\\\"\", \"description\": \"Randomly select one package with Python\"}"}} +{"type":"tool/result","seq":635,"time":1786073827271,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_00_flxIP8K77cJ8bKt5YRol0386"},"content":[{"type":"tool-result","toolCallId":"call_00_flxIP8K77cJ8bKt5YRol0386","content":[{"type":"text","text":"packages/context/session-reference\n"}],"isError":false}],"role":"user","id":"cc94aec3-ab93-4069-8671-9e7f779206a7"}},"sourceEventSeqs":[634],"surfaceOp":"append"} +{"type":"step/end","seq":636,"time":1786073827271,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":637,"time":1786073827280,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":638,"time":1786073828452,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":639,"time0":1786073828453,"data":{"turn":1,"step":5,"index":0,"dt":[107,37,0,0,0,0,0,0,0,0,39,0,0,0,0,0,14,0,32,0,0,27,1,0,0,0,0,16,1,0,0],"texts":["The"," random"," pick"," for"," turn"," ","1"," is"," `","pack","ages","/","context","/s","ession","-reference","`."," Now"," I","'ll"," output"," its"," file"," structure","."," Let"," me"," get"," the"," file"," tree","."]}} +{"type":"assistant/chunk","seq":671,"time":1786073828796,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":672,"time0":1786073828797,"data":{"turn":1,"step":5,"index":1,"dt":[23,1,0,0,0,41,0,0,0,0,0,0,0,0,0,0,0,17,1,0,0,45,1,0,0,0,0,0,0,0,0,0,1,29,1,0,0,0,45,1,0,0,0,68,1,0,0,0,0,0,0,1,0],"id":"call_00_HNZzKyAOnDcNLscoKIdq3835","name":"bash","args":["","{","\"","command","\"",": ","\"","find"," packages","/","context","/s","ession","-reference"," -","type"," f"," -","not"," -","path"," '","*/","node","_modules","/*","'"," -","not"," -","path"," '*","/.","git","/*","'"," |"," sort","\"",", ","\"","description","\"",": ","\"","List"," all"," files"," in"," session","-reference"," package","\"","}"]}} +{"type":"assistant/chunk","seq":726,"time":1786073829105,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The random pick for turn 1 is `packages/context/session-reference`. Now I'll output its file structure. Let me get the file tree."}}}} +{"type":"assistant/chunk","seq":727,"time":1786073829106,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_HNZzKyAOnDcNLscoKIdq3835","name":"bash","arguments":"{\"command\": \"find packages/context/session-reference -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | sort\", \"description\": \"List all files in session-reference package\"}"}}}} +{"type":"assistant/chunk","seq":728,"time":1786073829106,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":94,"outputTokens":127,"cacheReadTokens":8960,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":729,"time":1786073829106,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":730,"time":1786073829107,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The random pick for turn 1 is `packages/context/session-reference`. Now I'll output its file structure. Let me get the file tree."},{"type":"tool-call","id":"call_00_HNZzKyAOnDcNLscoKIdq3835","name":"bash","arguments":"{\"command\": \"find packages/context/session-reference -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | sort\", \"description\": \"List all files in session-reference package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"69ced441-6663-4988-8962-90db37eb5784"},"usage":{"inputTokens":94,"outputTokens":127,"cacheReadTokens":8960,"reasoningTokens":32}},"sourceEventSeqs":[638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729],"surfaceOp":"append"} +{"type":"tool/call","seq":731,"time":1786073829108,"data":{"turn":1,"step":5,"callId":"call_00_HNZzKyAOnDcNLscoKIdq3835","name":"bash","arguments":"{\"command\": \"find packages/context/session-reference -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | sort\", \"description\": \"List all files in session-reference package\"}"}} +{"type":"tool/result","seq":732,"time":1786073829131,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"call_00_HNZzKyAOnDcNLscoKIdq3835"},"content":[{"type":"tool-result","toolCallId":"call_00_HNZzKyAOnDcNLscoKIdq3835","content":[{"type":"text","text":"packages/context/session-reference/README.md\npackages/context/session-reference/package.json\npackages/context/session-reference/src/index.ts\npackages/context/session-reference/src/uri.ts\npackages/context/session-reference/tests/session-reference.spec.ts\n"}],"isError":false}],"role":"user","id":"b718750e-91db-4932-b7b9-28f15239d51a"}},"sourceEventSeqs":[731],"surfaceOp":"append"} +{"type":"step/end","seq":733,"time":1786073829131,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":734,"time":1786073829140,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":735,"time":1786073830487,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":736,"time0":1786073830487,"data":{"turn":1,"step":6,"index":0,"dt":[115,24,0,31,1,0,0,0,30,1,0,15,0,0,0,0,19,0,1,0,0,0,24,1,26,0,19,0,0,0,0,0,17,0,0,0,0,0,24,0,0,0,0,0,24,0,32,0,0,0,26,0,26,0,45,1,29,1,0,0,0,0,0,1,0,0,40,0,0,0,23,0,27,0,1,20,31,0,0,0,0,21,28,0,28,1,0,1,0,14,19,0,27,1,0,28,0,0,43,0,0,0,0,0,1,0,0,26,0,0,1,0,0,18,1,0,0,19,1,0,0,28],"texts":["Turn"," ","1"," is"," done","."," Per"," the"," objective",":"," \"","你","做完","一个","turn","之后",",","直接","输出","内容",",","停止","\""," —"," after"," finishing"," a"," turn",","," directly"," output"," the"," content"," and"," stop","."," The"," system"," will"," open"," another"," turn",".\n\n","So"," I"," should"," just"," output"," the"," file"," structure"," of"," this"," randomly"," picked"," package"," (","pack","ages","/","context","/s","ession","-reference",")"," and"," stop","."," I"," should"," NOT"," mark"," the"," goal"," complete"," since"," there"," are"," ","2"," turns"," and"," this"," is"," only"," turn"," ","1","."," The"," objective"," says"," the"," system"," will"," open"," another"," turn"," —"," so"," I"," should"," leave"," the"," goal"," active"," for"," the"," next"," round",".\n\n","Let"," me"," output"," the"," file"," structure"," in"," a"," nice"," tree"," format","."]}} +{"type":"assistant/chunk","seq":859,"time":1786073831468,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":860,"time0":1786073831468,"data":{"turn":1,"step":6,"index":1,"dt":[0,0,18,1,25,1,0,0,17,1,0,26,0,0,0,0,0,21,0,0,0,0,0,57,25,0,0,0,24,0,0,0,13,0,1,0,0,0,26,0,0,0,0,31,0,1,0,0,0,0,19,1,0,0,0,26,1,0,0,0,16,1,0,0,0,16,0,1,0,0,11,1,26,0,0,29,0,0,0,0,0,0,19,1,0,26,1,0,0,0,0,18,0,31,0,0,0,0,35,0,0,0,0,0,0,31,1,0,0,0,0,0,18,0,0,0,0,17,0,0,18,1,0,0,0,21,0,0,21,1,0,0,22,0,1,23,0,0,0,0,28,1,0,0,0,24,0,0,0,0,36,1,0,0,0,26,0,1,0,0,35,0,15,0,0,0,20,0,18,14,0,24,1,0,0,21,23,1,32,1,0,20,1,26,0,0],"texts":["**","Turn"," ","1"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","context","/s","ession","-reference","`"," 📦","\n\n","```\n","session","-reference","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," ├","──"," index",".ts","\n","│"," "," └","──"," uri",".ts","\n","└","──"," tests","\n"," "," └","──"," session","-reference",".s","pec",".ts","\n","```\n\n","```\n","session","-reference","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," ├","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","\n","│"," "," └","──"," uri",".ts"," "," #"," ","会话","引用"," URI"," ","相关","实现","\n","└","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","这是一个","很","典型的","轻","量"," Type","Script"," ","包","结构",":","入口"," +"," ","实现"," +"," ","测试","。","这一","轮","到此","结束",",","等","系统","开启","下一个"," turn","。"]}} +{"type":"assistant/chunk","seq":1047,"time":1786073832620,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn.\n\nSo I should just output the file structure of this randomly picked package (packages/context/session-reference) and stop. I should NOT mark the goal complete since there are 2 turns and this is only turn 1. The objective says the system will open another turn — so I should leave the goal active for the next round.\n\nLet me output the file structure in a nice tree format."}}}} +{"type":"assistant/chunk","seq":1048,"time":1786073832621,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Turn 1 / 2** — 随机选中的包:`packages/context/session-reference` 📦\n\n```\nsession-reference\n├── README.md\n├── package.json\n├── src\n│ ├── index.ts\n│ └── uri.ts\n└── tests\n └── session-reference.spec.ts\n```\n\n```\nsession-reference/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ ├── index.ts # 入口,导出公共 API\n│ └── uri.ts # 会话引用 URI 相关实现\n└── tests/\n └── session-reference.spec.ts # 单元测试\n```\n\n这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。"}}}} +{"type":"assistant/chunk","seq":1049,"time":1786073832621,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":318,"cacheReadTokens":9088,"reasoningTokens":123}}}} +{"type":"assistant/chunk","seq":1050,"time":1786073832621,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":1051,"time":1786073832623,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn.\n\nSo I should just output the file structure of this randomly picked package (packages/context/session-reference) and stop. I should NOT mark the goal complete since there are 2 turns and this is only turn 1. The objective says the system will open another turn — so I should leave the goal active for the next round.\n\nLet me output the file structure in a nice tree format."},{"type":"text","text":"**Turn 1 / 2** — 随机选中的包:`packages/context/session-reference` 📦\n\n```\nsession-reference\n├── README.md\n├── package.json\n├── src\n│ ├── index.ts\n│ └── uri.ts\n└── tests\n └── session-reference.spec.ts\n```\n\n```\nsession-reference/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ ├── index.ts # 入口,导出公共 API\n│ └── uri.ts # 会话引用 URI 相关实现\n└── tests/\n └── session-reference.spec.ts # 单元测试\n```\n\n这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba308403-b6c1-4040-941a-3b955915272e"},"usage":{"inputTokens":166,"outputTokens":318,"cacheReadTokens":9088,"reasoningTokens":123}},"sourceEventSeqs":[735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050],"surfaceOp":"append"} +{"type":"step/end","seq":1052,"time":1786073832624,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":1053,"time":1786073832624,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","seq":1054,"time":1786073832634,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_round>\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nRound: 2/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-f0026545-8655-41e9-b346-43a91211db94","revision":1,"round":2},"role":"user","id":"2b6024c9-d237-4d86-9887-50d3f5d268f2"}]}} +{"type":"turn/start","seq":1055,"time":1786073832634,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":1056,"time":1786073832634,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":1057,"time":1786073832643,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":1058,"time":1786073832643,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nRound: 2/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-f0026545-8655-41e9-b346-43a91211db94","revision":1,"round":2},"role":"user","id":"2b6024c9-d237-4d86-9887-50d3f5d268f2"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":1059,"time":1786073833821,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":1060,"time0":1786073833822,"data":{"turn":2,"step":1,"index":0,"dt":[109,18,21,0,0,25,0,0,0,0,0,20,0,1,22,0,0,0,23,1,0,0,20,22,0,0,0,24,0,22,1,0,0,0,0,21,0,0,0,0,21,0,0,22,1,0],"texts":["This"," is"," turn"," ","2"," of"," ","2","."," I"," need"," to"," randomly"," pick"," another"," package"," and"," output"," its"," file"," structure","."," In"," the"," first"," turn"," I"," picked"," `","pack","ages","/","context","/s","ession","-reference","`."," Let"," me"," pick"," a"," different"," one"," randomly"," this"," time","."]}} +{"type":"assistant/chunk","seq":1107,"time":1786073834282,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":1108,"time0":1786073834283,"data":{"turn":2,"step":1,"index":1,"dt":[24,0,0,0,0,22,0,0,0,23,1,0,0,0,0,26,1,0,0,0,0,18,0,0,0,0,0,24,0,0,0,1,0,21,0,0,0,0,0,21,0,0,31,0,0,0,0,1,15,1,0,0,0,0,24,1,0,0,0,0,14,31,1,0,0,0,16,1,0,0,21,25,1,25,1,87],"id":"call_00_wwDXszkz3z9JwKb8jUXj2737","name":"bash","args":["","{","\"","command","\"",": ","\"","find"," packages"," -","max","depth"," ","2"," -","mind","epth"," ","2"," -","type"," d"," |"," sort"," >"," /","tmp","/pkg","list",".txt"," &&"," python","3"," -","c"," \\\"\\n","import"," random","\\n","pk","gs"," ="," open","('/","tmp","/pkg","list",".txt","').","read","().","split","()\\n","print","(random",".choice","(p","k","gs","))\\n","\\\"","\"",", ","\"","description","\"",": ","\"","Random","ly"," select"," another"," package"," with"," Python","\"","}"]}} +{"type":"assistant/chunk","seq":1185,"time":1786073834762,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."}}}} +{"type":"assistant/chunk","seq":1186,"time":1786073834763,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_wwDXszkz3z9JwKb8jUXj2737","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort > /tmp/pkglist.txt && python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\nprint(random.choice(pkgs))\\n\\\"\", \"description\": \"Randomly select another package with Python\"}"}}}} +{"type":"assistant/chunk","seq":1187,"time":1786073834763,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":389,"outputTokens":165,"cacheReadTokens":9216,"reasoningTokens":47}}}} +{"type":"assistant/chunk","seq":1188,"time":1786073834763,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1189,"time":1786073834764,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."},{"type":"tool-call","id":"call_00_wwDXszkz3z9JwKb8jUXj2737","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort > /tmp/pkglist.txt && python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\nprint(random.choice(pkgs))\\n\\\"\", \"description\": \"Randomly select another package with Python\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"03e233be-8102-47df-b2ea-d79addba714e"},"usage":{"inputTokens":389,"outputTokens":165,"cacheReadTokens":9216,"reasoningTokens":47}},"sourceEventSeqs":[1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094,1095,1096,1097,1098,1099,1100,1101,1102,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1133,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1163,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,1187,1188],"surfaceOp":"append"} +{"type":"tool/call","seq":1190,"time":1786073834765,"data":{"turn":2,"step":1,"callId":"call_00_wwDXszkz3z9JwKb8jUXj2737","name":"bash","arguments":"{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort > /tmp/pkglist.txt && python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\nprint(random.choice(pkgs))\\n\\\"\", \"description\": \"Randomly select another package with Python\"}"}} +{"type":"tool/result","seq":1191,"time":1786073834810,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_wwDXszkz3z9JwKb8jUXj2737"},"content":[{"type":"tool-result","toolCallId":"call_00_wwDXszkz3z9JwKb8jUXj2737","content":[{"type":"text","text":"packages/context/session-reference\n"}],"isError":false}],"role":"user","id":"cdcea50b-ecf8-43aa-a004-a3f28d1d2d93"}},"sourceEventSeqs":[1190],"surfaceOp":"append"} +{"type":"step/end","seq":1192,"time":1786073834810,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":1193,"time":1786073834820,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":1194,"time":1786073836630,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":1195,"time0":1786073836631,"data":{"turn":2,"step":2,"index":0,"dt":[118,30,1,0,32,0,0,0,0,0,0,1,31,11,1,0,0,21,0,0,25,0,18,28,1,0,0,0,24,0,0,0,18,1,0,0,22,26,1,0,1,26,40,26,0],"texts":["The"," random"," pick"," returned"," the"," same"," package"," as"," turn"," ","1","."," Since"," turn"," ","1"," already"," covered"," `","session","-reference","`,"," it"," would"," be"," better"," to"," pick"," a"," different"," one"," for"," variety","."," Let"," me"," re","-","roll",","," excluding"," the"," already","-covered"," package","."]}} +{"type":"assistant/chunk","seq":1241,"time":1786073837201,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":1242,"time0":1786073837201,"data":{"turn":2,"step":2,"index":1,"dt":[20,0,1,0,0,24,0,0,0,0,1,23,1,0,0,13,0,0,20,0,1,25,0,0,1,0,0,20,1,37,0,0,0,0,0,11,0,0,0,0,0,27,0,0,0,0,0,22,1,0,0,0,0,20,0,0,1,0,47,0,0,0,0,20,0,0,23,0,23,0,25,1,0,30,0,21],"id":"call_00_3K2lg9zGfGsTvuh19xv65220","name":"bash","args":["","{","\"","command","\"",": ","\"","python","3"," -","c"," \\\"\\n","import"," random","\\n","pk","gs"," ="," open","('/","tmp","/pkg","list",".txt","').","read","().","split","()\\n","c","andidates"," ="," [","p"," for"," p"," in"," pk","gs"," if"," p"," !="," '","pack","ages","/","context","/s","ession","-reference","']\\n","print","(random",".choice","(c","andidates","))\\n","\\\"","\"",", ","\"","description","\"",": ","\"","Re","-","roll"," excluding"," the"," package"," from"," turn"," ","1","\"","}"]}} +{"type":"assistant/chunk","seq":1319,"time":1786073837701,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."}}}} +{"type":"assistant/chunk","seq":1320,"time":1786073837702,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_3K2lg9zGfGsTvuh19xv65220","name":"bash","arguments":"{\"command\": \"python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}"}}}} +{"type":"assistant/chunk","seq":1321,"time":1786073837702,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":62,"outputTokens":164,"cacheReadTokens":9728,"reasoningTokens":46}}}} +{"type":"assistant/chunk","seq":1322,"time":1786073837702,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1323,"time":1786073837703,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."},{"type":"tool-call","id":"call_00_3K2lg9zGfGsTvuh19xv65220","name":"bash","arguments":"{\"command\": \"python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9cf59318-d083-4bc9-9780-d845bd387cb2"},"usage":{"inputTokens":62,"outputTokens":164,"cacheReadTokens":9728,"reasoningTokens":46}},"sourceEventSeqs":[1194,1195,1196,1197,1198,1199,1200,1201,1202,1203,1204,1205,1206,1207,1208,1209,1210,1211,1212,1213,1214,1215,1216,1217,1218,1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231,1232,1233,1234,1235,1236,1237,1238,1239,1240,1241,1242,1243,1244,1245,1246,1247,1248,1249,1250,1251,1252,1253,1254,1255,1256,1257,1258,1259,1260,1261,1262,1263,1264,1265,1266,1267,1268,1269,1270,1271,1272,1273,1274,1275,1276,1277,1278,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291,1292,1293,1294,1295,1296,1297,1298,1299,1300,1301,1302,1303,1304,1305,1306,1307,1308,1309,1310,1311,1312,1313,1314,1315,1316,1317,1318,1319,1320,1321,1322],"surfaceOp":"append"} +{"type":"tool/call","seq":1324,"time":1786073837704,"data":{"turn":2,"step":2,"callId":"call_00_3K2lg9zGfGsTvuh19xv65220","name":"bash","arguments":"{\"command\": \"python3 -c \\\"\\nimport random\\npkgs = open('/tmp/pkglist.txt').read().split()\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}"}} +{"type":"tool/result","seq":1325,"time":1786073837745,"data":{"turn":2,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_3K2lg9zGfGsTvuh19xv65220"},"content":[{"type":"tool-result","toolCallId":"call_00_3K2lg9zGfGsTvuh19xv65220","content":[{"type":"text","text":"packages/llm/token-meter\n"}],"isError":false}],"role":"user","id":"46088fbd-4f65-43ca-9213-a39a236746e8"}},"sourceEventSeqs":[1324],"surfaceOp":"append"} +{"type":"step/end","seq":1326,"time":1786073837745,"data":{"turn":2,"step":2}} +{"type":"step/start","seq":1327,"time":1786073837753,"data":{"turn":2,"step":3}} +{"type":"assistant/chunk","seq":1328,"time":1786073839239,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":1329,"time0":1786073839240,"data":{"turn":2,"step":3,"index":0,"dt":[0,1,0,0,0,18,1,0,0,0,0,36,1,0,0,0,0,0,0,0,0,0,1,33,1,0,0,0,0,19,1,0,0,0,0,22,0,0,0,20,0,26,1,0,0,23,0,0,0,1,20,0,0,25],"id":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","name":"bash","args":["","{","\"","command","\"",": ","\"","find"," packages","/","ll","m","/t","oken","-meter"," -","type"," f"," -","not"," -","path"," '","*/","node","_modules","/*","'"," -","not"," -","path"," '*","/.","git","/*","'"," |"," sort","\"",", ","\"","description","\"",": ","\"","List"," all"," files"," in"," token","-meter"," package","\"","}"]}} +{"type":"assistant/chunk","seq":1384,"time":1786073839525,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","name":"bash","arguments":"{\"command\": \"find packages/llm/token-meter -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | sort\", \"description\": \"List all files in token-meter package\"}"}}}} +{"type":"assistant/chunk","seq":1385,"time":1786073839526,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":119,"outputTokens":96,"cacheReadTokens":9856,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":1386,"time":1786073839526,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1387,"time":1786073839527,"data":{"turn":2,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","name":"bash","arguments":"{\"command\": \"find packages/llm/token-meter -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | sort\", \"description\": \"List all files in token-meter package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"24efebe7-3b07-48f8-9e33-2112b1836615"},"usage":{"inputTokens":119,"outputTokens":96,"cacheReadTokens":9856,"reasoningTokens":0}},"sourceEventSeqs":[1328,1329,1330,1331,1332,1333,1334,1335,1336,1337,1338,1339,1340,1341,1342,1343,1344,1345,1346,1347,1348,1349,1350,1351,1352,1353,1354,1355,1356,1357,1358,1359,1360,1361,1362,1363,1364,1365,1366,1367,1368,1369,1370,1371,1372,1373,1374,1375,1376,1377,1378,1379,1380,1381,1382,1383,1384,1385,1386],"surfaceOp":"append"} +{"type":"tool/call","seq":1388,"time":1786073839528,"data":{"turn":2,"step":3,"callId":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","name":"bash","arguments":"{\"command\": \"find packages/llm/token-meter -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | sort\", \"description\": \"List all files in token-meter package\"}"}} +{"type":"tool/result","seq":1389,"time":1786073839550,"data":{"turn":2,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_mh4Jb1yklK6bS4luMCFO2893","content":[{"type":"text","text":"packages/llm/token-meter/README.md\npackages/llm/token-meter/package.json\npackages/llm/token-meter/src/index.ts\npackages/llm/token-meter/tests/token-meter.spec.ts\n"}],"isError":false}],"role":"user","id":"70e95cf8-4c6c-4de3-bbbb-bcf3104e564c"}},"sourceEventSeqs":[1388],"surfaceOp":"append"} +{"type":"step/end","seq":1390,"time":1786073839550,"data":{"turn":2,"step":3}} +{"type":"step/start","seq":1391,"time":1786073839559,"data":{"turn":2,"step":4}} +{"type":"assistant/chunk","seq":1392,"time":1786073841076,"data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":1393,"time0":1786073841077,"data":{"turn":2,"step":4,"index":0,"dt":[129,46,0,1,0,0,0,0,30,1,0,34,0,0,0,0,0,0,0,50,0,0,0,0,0,46,1,0,0,0,0,0,0,0,0,0,0,39,0,0,0,0,0,0,0,0,31,0,14,1,13],"texts":["This"," is"," turn"," ","2"," of"," ","2","."," I","'ve"," output"," the"," file"," structure"," for"," `","pack","ages","/","ll","m","/t","oken","-meter","`."," Both"," turns"," are"," done","."," I"," should"," output"," the"," content",","," stop",","," and"," then"," mark"," the"," goal"," as"," complete"," since"," both"," turns"," are"," finished","."]}} +{"type":"assistant/chunk","seq":1445,"time":1786073841539,"data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":1446,"time0":1786073841539,"data":{"turn":2,"step":4,"index":1,"dt":[0,0,1,20,1,0,0,0,0,24,0,0,0,0,0,20,0,0,0,0,0,32,0,0,0,19,1,0,0,33,1,0,0,0,0,31,0,0,0,0,1,0,39,0,0,0,0,1,0,0,0,4,0,0,0,0,1,21,0,0,0,1,0,17,1,0,0,0,25,0,0,0,0,0,25,0,0,0,0,26,0,0,1,0,0,11,1,0,0,0,24,0,0,0,0,26,0,0,0,0,0,26,0,0,0,0,15,1,0,0,19,0,0,24,0,1,0,18,28,0,0,0,0,20,1,0,0,0,25,0,0,0,0,23,1,0,0,0,23,1,0,15,1,0,21,26,33,1,0,0,0,0,28,0,0,0,0,0,35,0,1,0,0,0,0,0,34,1],"texts":["**","Turn"," ","2"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","ll","m","/t","oken","-meter","`"," 📦","\n\n","```\n","token","-meter","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," └","──"," index",".ts","\n","└","──"," tests","\n"," "," └","──"," token","-meter",".s","pec",".ts","\n","```\n\n","```\n","token","-meter","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," └","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","(","token"," ","计量","逻辑",")\n","└","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","两个"," turn"," ","都","完成了","(","turn"," ","1",":","`","session","-reference","`",",","turn"," ","2",":","`","token","-meter","`",")。","这一","轮","到此","结束","。"]}} +{"type":"assistant/chunk","seq":1615,"time":1786073842416,"data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":1616,"time":1786073842416,"data":{"turn":2,"step":4,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":1617,"time":1786073842416,"data":{"turn":2,"step":4,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","argumentsDelta":"{}"}}} +{"type":"assistant/chunk","seq":1618,"time":1786073842492,"data":{"turn":2,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"This is turn 2 of 2. I've output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."}}}} +{"type":"assistant/chunk","seq":1619,"time":1786073842492,"data":{"turn":2,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Turn 2 / 2** — 随机选中的包:`packages/llm/token-meter` 📦\n\n```\ntoken-meter\n├── README.md\n├── package.json\n├── src\n│ └── index.ts\n└── tests\n └── token-meter.spec.ts\n```\n\n```\ntoken-meter/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ └── index.ts # 入口,导出公共 API(token 计量逻辑)\n└── tests/\n └── token-meter.spec.ts # 单元测试\n```\n\n两个 turn 都完成了(turn 1:`session-reference`,turn 2:`token-meter`)。这一轮到此结束。"}}}} +{"type":"assistant/chunk","seq":1620,"time":1786073842493,"data":{"turn":2,"step":4,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","arguments":"{}"}}}} +{"type":"assistant/chunk","seq":1621,"time":1786073842493,"data":{"turn":2,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":152,"outputTokens":256,"cacheReadTokens":9984,"reasoningTokens":52}}}} +{"type":"assistant/chunk","seq":1622,"time":1786073842493,"data":{"turn":2,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1623,"time":1786073842494,"data":{"turn":2,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"This is turn 2 of 2. I've output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."},{"type":"text","text":"**Turn 2 / 2** — 随机选中的包:`packages/llm/token-meter` 📦\n\n```\ntoken-meter\n├── README.md\n├── package.json\n├── src\n│ └── index.ts\n└── tests\n └── token-meter.spec.ts\n```\n\n```\ntoken-meter/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ └── index.ts # 入口,导出公共 API(token 计量逻辑)\n└── tests/\n └── token-meter.spec.ts # 单元测试\n```\n\n两个 turn 都完成了(turn 1:`session-reference`,turn 2:`token-meter`)。这一轮到此结束。"},{"type":"tool-call","id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"295abdc5-327b-4fc8-bb10-f6d23a08a9dd"},"usage":{"inputTokens":152,"outputTokens":256,"cacheReadTokens":9984,"reasoningTokens":52}},"sourceEventSeqs":[1392,1393,1394,1395,1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406,1407,1408,1409,1410,1411,1412,1413,1414,1415,1416,1417,1418,1419,1420,1421,1422,1423,1424,1425,1426,1427,1428,1429,1430,1431,1432,1433,1434,1435,1436,1437,1438,1439,1440,1441,1442,1443,1444,1445,1446,1447,1448,1449,1450,1451,1452,1453,1454,1455,1456,1457,1458,1459,1460,1461,1462,1463,1464,1465,1466,1467,1468,1469,1470,1471,1472,1473,1474,1475,1476,1477,1478,1479,1480,1481,1482,1483,1484,1485,1486,1487,1488,1489,1490,1491,1492,1493,1494,1495,1496,1497,1498,1499,1500,1501,1502,1503,1504,1505,1506,1507,1508,1509,1510,1511,1512,1513,1514,1515,1516,1517,1518,1519,1520,1521,1522,1523,1524,1525,1526,1527,1528,1529,1530,1531,1532,1533,1534,1535,1536,1537,1538,1539,1540,1541,1542,1543,1544,1545,1546,1547,1548,1549,1550,1551,1552,1553,1554,1555,1556,1557,1558,1559,1560,1561,1562,1563,1564,1565,1566,1567,1568,1569,1570,1571,1572,1573,1574,1575,1576,1577,1578,1579,1580,1581,1582,1583,1584,1585,1586,1587,1588,1589,1590,1591,1592,1593,1594,1595,1596,1597,1598,1599,1600,1601,1602,1603,1604,1605,1606,1607,1608,1609,1610,1611,1612,1613,1614,1615,1616,1617,1618,1619,1620,1621,1622],"surfaceOp":"append"} +{"type":"tool/call","seq":1624,"time":1786073842495,"data":{"turn":2,"step":4,"callId":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","arguments":"{}"}} +{"type":"tool/result","seq":1625,"time":1786073842506,"data":{"turn":2,"step":4,"message":{"source":{"kind":"tool","callId":"call_00_AKGba6PMDSJdtJI2ZhAz7514"},"content":[{"type":"tool-result","toolCallId":"call_00_AKGba6PMDSJdtJI2ZhAz7514","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-f0026545-8655-41e9-b346-43a91211db94\",\"revision\":1,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"active\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"257c9c9b-5c0c-44cd-a20b-2745be6dc087"}},"sourceEventSeqs":[1624],"surfaceOp":"append"} +{"type":"step/end","seq":1626,"time":1786073842506,"data":{"turn":2,"step":4}} +{"type":"step/start","seq":1627,"time":1786073842514,"data":{"turn":2,"step":5}} +{"type":"assistant/chunk","seq":1628,"time":1786073843610,"data":{"turn":2,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":1629,"time0":1786073843610,"data":{"turn":2,"step":5,"index":0,"dt":[104,23,1,0,24,1,0,0,0,23,0,0,20,0,24,0,0,0,26,1,0,0,16,0,24,1,0,22,0,0,1,22,0,0,25,1,0,18,24,26,22,1,0],"texts":["The"," objective"," was"," \"","做","两个","turn","\""," (","do"," two"," turns","),"," and"," both"," turns"," are"," now"," complete","."," Turn"," ","1"," output"," session","-reference",","," turn"," ","2"," output"," token","-meter","."," The"," objective"," is"," achieved"," —"," mark"," the"," goal"," complete","."]}} +{"type":"assistant/chunk","seq":1673,"time":1786073844060,"data":{"turn":2,"step":5,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":1674,"time0":1786073844060,"data":{"turn":2,"step":5,"index":1,"dt":[22,0,0,0,0,22,1,0,40,0,5,0,24,0],"texts":["两个"," turn"," ","均","已完成",",","目标","达成",",","标记"," goal"," ","为","完成","。"]}} +{"type":"assistant/chunk","seq":1689,"time":1786073844215,"data":{"turn":2,"step":5,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":1690,"time0":1786073844215,"data":{"turn":2,"step":5,"index":2,"dt":[23,0,0,22,1,0,0,27,0,0,0,0,19,1,0,0,0,21,0,0,0,0,25,0,0,0,0,0,18,28,0,0,0,0,18,0,50,0,0,0,0,22,1,0,26],"id":"call_00_5KtqpQWh0ErEMqidxC795565","name":"update_goal","args":["","{","\"","goal","_id","\"",": ","\"","goal","-f","002","654","5","-","865","5","-","41","e","9","-b","346","-","43","a","912","11","db","94","\"",", ","\"","re","vision","\"",": ","1",", ","\"","action","\"",": ","\"","complete","\"","}"]}} +{"type":"assistant/chunk","seq":1736,"time":1786073844575,"data":{"turn":2,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The objective was \"做两个turn\" (do two turns), and both turns are now complete. Turn 1 output session-reference, turn 2 output token-meter. The objective is achieved — mark the goal complete."}}}} +{"type":"assistant/chunk","seq":1737,"time":1786073844575,"data":{"turn":2,"step":5,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"两个 turn 均已完成,目标达成,标记 goal 为完成。"}}}} +{"type":"assistant/chunk","seq":1738,"time":1786073844575,"data":{"turn":2,"step":5,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_5KtqpQWh0ErEMqidxC795565","name":"update_goal","arguments":"{\"goal_id\": \"goal-f0026545-8655-41e9-b346-43a91211db94\", \"revision\": 1, \"action\": \"complete\"}"}}}} +{"type":"assistant/chunk","seq":1739,"time":1786073844576,"data":{"turn":2,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":132,"outputTokens":157,"cacheReadTokens":10368,"reasoningTokens":44}}}} +{"type":"assistant/chunk","seq":1740,"time":1786073844576,"data":{"turn":2,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1741,"time":1786073844577,"data":{"turn":2,"step":5,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The objective was \"做两个turn\" (do two turns), and both turns are now complete. Turn 1 output session-reference, turn 2 output token-meter. The objective is achieved — mark the goal complete."},{"type":"text","text":"两个 turn 均已完成,目标达成,标记 goal 为完成。"},{"type":"tool-call","id":"call_00_5KtqpQWh0ErEMqidxC795565","name":"update_goal","arguments":"{\"goal_id\": \"goal-f0026545-8655-41e9-b346-43a91211db94\", \"revision\": 1, \"action\": \"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"68ad1ad0-9a5f-42c0-8881-9fe5c0047ef5"},"usage":{"inputTokens":132,"outputTokens":157,"cacheReadTokens":10368,"reasoningTokens":44}},"sourceEventSeqs":[1628,1629,1630,1631,1632,1633,1634,1635,1636,1637,1638,1639,1640,1641,1642,1643,1644,1645,1646,1647,1648,1649,1650,1651,1652,1653,1654,1655,1656,1657,1658,1659,1660,1661,1662,1663,1664,1665,1666,1667,1668,1669,1670,1671,1672,1673,1674,1675,1676,1677,1678,1679,1680,1681,1682,1683,1684,1685,1686,1687,1688,1689,1690,1691,1692,1693,1694,1695,1696,1697,1698,1699,1700,1701,1702,1703,1704,1705,1706,1707,1708,1709,1710,1711,1712,1713,1714,1715,1716,1717,1718,1719,1720,1721,1722,1723,1724,1725,1726,1727,1728,1729,1730,1731,1732,1733,1734,1735,1736,1737,1738,1739,1740],"surfaceOp":"append"} +{"type":"tool/call","seq":1742,"time":1786073844578,"data":{"turn":2,"step":5,"callId":"call_00_5KtqpQWh0ErEMqidxC795565","name":"update_goal","arguments":"{\"goal_id\": \"goal-f0026545-8655-41e9-b346-43a91211db94\", \"revision\": 1, \"action\": \"complete\"}"}} +{"type":"goal/change","seq":1743,"time":1786073844589,"data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-f0026545-8655-41e9-b346-43a91211db94","revision":2,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"complete","maxGoalRounds":256},"roundsStarted":2,"createdAt":1786073817206,"updatedAt":1786073844589}} +{"type":"tool/result","seq":1744,"time":1786073844590,"data":{"turn":2,"step":5,"message":{"source":{"kind":"tool","callId":"call_00_5KtqpQWh0ErEMqidxC795565"},"content":[{"type":"tool-result","toolCallId":"call_00_5KtqpQWh0ErEMqidxC795565","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-f0026545-8655-41e9-b346-43a91211db94\",\"revision\":2,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"complete\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"a3ffb007-43b9-4d6b-9a4a-b9d9791803d1"}},"sourceEventSeqs":[1742],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","seq":1745,"time":1786073844590,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_complete>\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n</goal_complete>"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"},"role":"user","id":"34261fc8-220c-40c0-819d-c9c0332bf965"}]}} +{"type":"step/end","seq":1746,"time":1786073844591,"data":{"turn":2,"step":5}} +{"type":"agent/inbox/spliced","seq":1747,"time":1786073844591,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":1748,"time":1786073844598,"data":{"turn":2,"step":6}} +{"type":"user/message","seq":1749,"time":1786073844598,"data":{"content":[{"type":"text","text":"<goal_complete>\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n</goal_complete>"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"},"role":"user","id":"34261fc8-220c-40c0-819d-c9c0332bf965"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":1750,"time":1786073845862,"data":{"turn":2,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"text-chunks","seq0":1751,"time0":1786073845863,"data":{"turn":2,"step":6,"index":0,"dt":[22,1,0,0,22,1,0,0,0,22,0,0,0,0,0,24,0,25,0,0,0,22,1,0,19,1,0,0,23,0,0,27,0,20,0,0,25,0,1,0,0,0,21,0,1,0,0,23,0,0,0,0,0,24,0,0,0,0,0,0,24,0,0,0,0,0,20,0,1,0,0,27,0,0,0,1,20,0,0,1,0,24,0,1,0,0,0,19,0,0,0,0,0,24,0,0,0,0,0,24,1,0,0,0,0,20,0,0,0,0,24,0,0,0,0,24,0,0,1,0,0,23,0,0,0,0,0,23,1,0,0,0,0,22,0,0,1,0,0,22,1,0,0,0,24,1,0,0,0,0,26,0,0,0,1,21,0,0,0,0,24,0,0,0,0,0,26,0,0,0,0,0,20,20,24,23,1,24,0,22,0,45,3,1,0,25,1,23,0,0,0,0,25,0,0,1,17,0,23,26,1,0,0,23,0,0,23,0,22,28,1,0,0,0,0,23,0,0,0,0,1,23,0,1,0,0,0,21,0,1,0,22,0,0,0,23,0,0,24,22,25,21,38,1,0,10,24,27,1,0,0,26,0,0,0,1,0,19,21,27,0,0,29,0,0,0,0,0,23,20,0,21,25,1,54,0,0,30,0,0,0,0,22,24,1,0,25,0,1,19,24,0,24,1,0,23,1,22,0,0,22,1,25,21,0,22,23,28,0,23,1,0,0,0,22,0,0,0,0,0,24,0,1,0,0,0,18,24,26,1,0,20,23,24,1,26,0,1,0,39],"texts":["两个"," turn"," ","都","完成了"," ✅","\n\n","**","总结",":","**\n\n","-"," **","Turn"," ","1","**"," —"," ","随机","选中"," `","pack","ages","/","context","/s","ession","-reference","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," session","-reference","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," ├","──"," index",".ts","\n"," "," │"," "," └","──"," uri",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts","\n"," "," ```\n","-"," **","Turn"," ","2","**"," —"," ","随机","选中"," `","pack","ages","/","ll","m","/t","oken","-meter","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," token","-meter","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," └","──"," index",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts","\n"," "," ```\n\n","**","验证","方式",":","**"," ","两个","包的","随机","选择","都","通过"," `","python","3"," random",".choice","`"," ","从","工作","区的"," `","pack","ages","/","`"," ","目录","下","实际","枚举","的"," ","4"," ","个","候选","包","中","抽取","(","`","ui","-con","vers","ation","`","、","`","session","-reference","`","、","`","token","-meter","`","、","`","skill","-local","`","),","文件","结构","则","用"," `","find","`"," ","实际","列出","文件","后","整理","输出",",","均已","排除"," `","node","_modules","`"," ","和"," `.","git","`","。","第二次","随机","抽","到了","与"," turn"," ","1"," ","相同的","包",",","为避免","重复","换","抽","了","另一个","。\n\n","**","说明",":","**"," ","本轮","没有","产生","任何","文件","修改","或","提交","——","任务","本身就是","\"","输出","文件","结构","\",","所有","产物","都在","上面的","消息","文本","里","。\n\n","如果你","还想","看","剩下","两个","包","(","`","ui","-con","vers","ation","`","、","`","skill","-local","`",")","的结构",",","或者其他","格式","的输出","(","比如","带","文件","大小的","树","形","图","),","随时","告诉我","。"]}} +{"type":"assistant/chunk","seq":2092,"time":1786073848306,"data":{"turn":2,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"两个 turn 都完成了 ✅\n\n**总结:**\n\n- **Turn 1** — 随机选中 `packages/context/session-reference`,输出了文件结构:\n ```\n session-reference/\n ├── README.md\n ├── package.json\n ├── src/\n │ ├── index.ts\n │ └── uri.ts\n └── tests/\n └── session-reference.spec.ts\n ```\n- **Turn 2** — 随机选中 `packages/llm/token-meter`,输出了文件结构:\n ```\n token-meter/\n ├── README.md\n ├── package.json\n ├── src/\n │ └── index.ts\n └── tests/\n └── token-meter.spec.ts\n ```\n\n**验证方式:** 两个包的随机选择都通过 `python3 random.choice` 从工作区的 `packages/` 目录下实际枚举的 4 个候选包中抽取(`ui-conversation`、`session-reference`、`token-meter`、`skill-local`),文件结构则用 `find` 实际列出文件后整理输出,均已排除 `node_modules` 和 `.git`。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。\n\n**说明:** 本轮没有产生任何文件修改或提交——任务本身就是\"输出文件结构\",所有产物都在上面的消息文本里。\n\n如果你还想看剩下两个包(`ui-conversation`、`skill-local`)的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。"}}}} +{"type":"assistant/chunk","seq":2093,"time":1786073848307,"data":{"turn":2,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":311,"outputTokens":348,"cacheReadTokens":10624,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":2094,"time":1786073848307,"data":{"turn":2,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":2095,"time":1786073848307,"data":{"turn":2,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"两个 turn 都完成了 ✅\n\n**总结:**\n\n- **Turn 1** — 随机选中 `packages/context/session-reference`,输出了文件结构:\n ```\n session-reference/\n ├── README.md\n ├── package.json\n ├── src/\n │ ├── index.ts\n │ └── uri.ts\n └── tests/\n └── session-reference.spec.ts\n ```\n- **Turn 2** — 随机选中 `packages/llm/token-meter`,输出了文件结构:\n ```\n token-meter/\n ├── README.md\n ├── package.json\n ├── src/\n │ └── index.ts\n └── tests/\n └── token-meter.spec.ts\n ```\n\n**验证方式:** 两个包的随机选择都通过 `python3 random.choice` 从工作区的 `packages/` 目录下实际枚举的 4 个候选包中抽取(`ui-conversation`、`session-reference`、`token-meter`、`skill-local`),文件结构则用 `find` 实际列出文件后整理输出,均已排除 `node_modules` 和 `.git`。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。\n\n**说明:** 本轮没有产生任何文件修改或提交——任务本身就是\"输出文件结构\",所有产物都在上面的消息文本里。\n\n如果你还想看剩下两个包(`ui-conversation`、`skill-local`)的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1daadd-6f23-49ea-980b-6f96f67e3f52"},"usage":{"inputTokens":311,"outputTokens":348,"cacheReadTokens":10624,"reasoningTokens":0}},"sourceEventSeqs":[1750,1751,1752,1753,1754,1755,1756,1757,1758,1759,1760,1761,1762,1763,1764,1765,1766,1767,1768,1769,1770,1771,1772,1773,1774,1775,1776,1777,1778,1779,1780,1781,1782,1783,1784,1785,1786,1787,1788,1789,1790,1791,1792,1793,1794,1795,1796,1797,1798,1799,1800,1801,1802,1803,1804,1805,1806,1807,1808,1809,1810,1811,1812,1813,1814,1815,1816,1817,1818,1819,1820,1821,1822,1823,1824,1825,1826,1827,1828,1829,1830,1831,1832,1833,1834,1835,1836,1837,1838,1839,1840,1841,1842,1843,1844,1845,1846,1847,1848,1849,1850,1851,1852,1853,1854,1855,1856,1857,1858,1859,1860,1861,1862,1863,1864,1865,1866,1867,1868,1869,1870,1871,1872,1873,1874,1875,1876,1877,1878,1879,1880,1881,1882,1883,1884,1885,1886,1887,1888,1889,1890,1891,1892,1893,1894,1895,1896,1897,1898,1899,1900,1901,1902,1903,1904,1905,1906,1907,1908,1909,1910,1911,1912,1913,1914,1915,1916,1917,1918,1919,1920,1921,1922,1923,1924,1925,1926,1927,1928,1929,1930,1931,1932,1933,1934,1935,1936,1937,1938,1939,1940,1941,1942,1943,1944,1945,1946,1947,1948,1949,1950,1951,1952,1953,1954,1955,1956,1957,1958,1959,1960,1961,1962,1963,1964,1965,1966,1967,1968,1969,1970,1971,1972,1973,1974,1975,1976,1977,1978,1979,1980,1981,1982,1983,1984,1985,1986,1987,1988,1989,1990,1991,1992,1993,1994,1995,1996,1997,1998,1999,2000,2001,2002,2003,2004,2005,2006,2007,2008,2009,2010,2011,2012,2013,2014,2015,2016,2017,2018,2019,2020,2021,2022,2023,2024,2025,2026,2027,2028,2029,2030,2031,2032,2033,2034,2035,2036,2037,2038,2039,2040,2041,2042,2043,2044,2045,2046,2047,2048,2049,2050,2051,2052,2053,2054,2055,2056,2057,2058,2059,2060,2061,2062,2063,2064,2065,2066,2067,2068,2069,2070,2071,2072,2073,2074,2075,2076,2077,2078,2079,2080,2081,2082,2083,2084,2085,2086,2087,2088,2089,2090,2091,2092,2093,2094],"surfaceOp":"append"} +{"type":"step/end","seq":2096,"time":1786073848308,"data":{"turn":2,"step":6}} +{"type":"turn/end","seq":2097,"time":1786073848308,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md new file mode 100644 index 0000000000..b1c0cb52ca --- /dev/null +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -0,0 +1,200 @@ +- banner: + - navigation "Session hierarchy": + - button "workspace" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"': + - img + - img + - text: "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear" +- button "Context injection goal": + - img + - img + - text: Context injection goal +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- 'button "Think The user''s goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""': + - img + - img + - text: "Think The user's goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\"" +- button "Bash Show working directory and its contents": + - img + - img + - text: Bash Show working directory and its contents +- 'button "Tool call get_goal · {}"': + - img + - img + - text: "Tool call get_goal · {}" +- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."': + - img + - img + - text: "Think There's a `packages` directory. Let me look inside to see what packages are available." +- button "Bash List packages directory contents": + - img + - img + - text: Bash List packages directory contents +- button "Think There are 4 packages:": + - img + - img + - text: "Think There are 4 packages:" +- button "Failed Bash Randomly pick one package directory": + - img + - text: Failed Bash Randomly pick one package directory +- 'button "Think shuf is not available on macOS. I''ll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."': + - img + - img + - text: "Think shuf is not available on macOS. I'll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python." +- button "Bash Randomly select one package with Python": + - img + - img + - text: Bash Randomly select one package with Python +- 'button "Think The random pick for turn 1 is `packages/context/session-reference`. Now I''ll output its file structure. Let me get the file tree."': + - img + - img + - text: "Think The random pick for turn 1 is `packages/context/session-reference`. Now I'll output its file structure. Let me get the file tree." +- button "Bash List all files in session-reference package": + - img + - img + - text: Bash List all files in session-reference package +- 'button "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."': + - img + - img + - text: "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn." +- paragraph: + - strong: Turn 1 / 2 + - text: — 随机选中的包: + - code: packages/context/session-reference + - text: 📦 +- button "Copy" +- code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts +- button "Copy" +- code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试" +- paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "Context injection goal": + - img + - img + - text: Context injection goal +- 'button "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."': + - img + - img + - text: "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time." +- button "Bash Randomly select another package with Python": + - img + - img + - text: Bash Randomly select another package with Python +- 'button "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."': + - img + - img + - text: "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package." +- button "Bash Re-roll excluding the package from turn 1": + - img + - img + - text: Bash Re-roll excluding the package from turn 1 +- button "Bash List all files in token-meter package": + - img + - img + - text: Bash List all files in token-meter package +- 'button "Think This is turn 2 of 2. I''ve output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."': + - img + - img + - text: "Think This is turn 2 of 2. I've output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished." +- paragraph: + - strong: Turn 2 / 2 + - text: — 随机选中的包: + - code: packages/llm/token-meter + - text: 📦 +- button "Copy" +- code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts +- button "Copy" +- code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试" +- paragraph: + - text: 两个 turn 都完成了(turn 1: + - code: session-reference + - text: ,turn 2: + - code: token-meter + - text: )。这一轮到此结束。 +- 'button "Tool call get_goal · {}"': + - img + - img + - text: "Tool call get_goal · {}" +- paragraph: 两个 turn 均已完成,目标达成,标记 goal 为完成。 +- button "Tool call update_goal · goal-{{uuid}}": + - img + - img + - text: Tool call update_goal · goal-{{uuid}} +- 'button "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"': + - img + - img + - text: "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" +- paragraph: 两个 turn 都完成了 ✅ +- paragraph: + - strong: 总结: +- list: + - listitem: + - strong: Turn 1 + - text: — 随机选中 + - code: packages/context/session-reference + - text: ,输出了文件结构: + - button "Copy" + - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts + - listitem: + - strong: Turn 2 + - text: — 随机选中 + - code: packages/llm/token-meter + - text: ,输出了文件结构: + - button "Copy" + - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts +- paragraph: + - strong: 验证方式: + - text: 两个包的随机选择都通过 + - code: python3 random.choice + - text: 从工作区的 + - code: packages/ + - text: 目录下实际枚举的 4 个候选包中抽取( + - code: ui-conversation + - text: 、 + - code: session-reference + - text: 、 + - code: token-meter + - text: 、 + - code: skill-local + - text: ),文件结构则用 + - code: find + - text: 实际列出文件后整理输出,均已排除 + - code: node_modules + - text: 和 + - code: .git + - text: 。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。 +- paragraph: + - strong: 说明: + - text: 本轮没有产生任何文件修改或提交——任务本身就是"输出文件结构",所有产物都在上面的消息文本里。 +- paragraph: + - text: 如果你还想看剩下两个包( + - code: ui-conversation + - text: 、 + - code: skill-local + - text: )的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。 +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- tooltip "Branch into a new conversation" +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "9% of context used" +- button "Send message" [disabled] +- text: 2 turns · 12 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 91% Input 113K tok · Output 2.4K tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 665733f237..fab71a9c0d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/subagent-conversation.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", + "tests/goal-multi-turn-actions.e2e.ts", "tests/chat-scroll-fixture.ts", "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index db5b70fe2f..faa9bae9dd 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -50,6 +50,7 @@ "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", + "apps/web/tests/goal-multi-turn-actions.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", From 666e6913274ce7360bf9ebd64f73c55d614bf40b Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Fri, 7 Aug 2026 12:20:24 +0800 Subject: [PATCH 182/190] test(web): make Goal replay platform-independent --- .../replay.override.json | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json b/apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json index 58bddf8677..456e58145e 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json @@ -1,5 +1,37 @@ { "patches": [ + { + "at": 2, + "entry": { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "reasoning" }, + { "type": "reasoning-delta", "index": 0, "text": "There are 4 packages:\n- client/ui-conversation\n- context/session-reference\n- llm/token-meter\n- skill/skill-local\n\nFor this turn (turn 1 of 2), I should randomly pick one package and output its file structure. Let me pick randomly — I'll use $RANDOM or just pick one. Let me use a quick random selection." }, + { "type": "block-end", "index": 0, "block": { "type": "reasoning", "text": "There are 4 packages:\n- client/ui-conversation\n- context/session-reference\n- llm/token-meter\n- skill/skill-local\n\nFor this turn (turn 1 of 2), I should randomly pick one package and output its file structure. Let me pick randomly — I'll use $RANDOM or just pick one. Let me use a quick random selection." } }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 1, "id": "call_00_dAM4qfVp4LLtRCiJi7d71418", "name": "bash", "argumentsDelta": "{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort | awk '{print NR\\\": \\\"$0}' && echo \\\"---random pick---\\\" && find packages -maxdepth 2 -mindepth 2 -type d | sort | PATH=/__dsh_fixture_missing__ shuf -n 1\", \"description\": \"Randomly pick one package directory\"}" }, + { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_00_dAM4qfVp4LLtRCiJi7d71418", "name": "bash", "arguments": "{\"command\": \"find packages -maxdepth 2 -mindepth 2 -type d | sort | awk '{print NR\\\": \\\"$0}' && echo \\\"---random pick---\\\" && find packages -maxdepth 2 -mindepth 2 -type d | sort | PATH=/__dsh_fixture_missing__ shuf -n 1\", \"description\": \"Randomly pick one package directory\"}" } }, + { "type": "usage", "usage": { "inputTokens": 156, "outputTokens": 202, "cacheReadTokens": 8448, "reasoningTokens": 80 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + } + }, + { + "at": 7, + "entry": { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "reasoning" }, + { "type": "reasoning-delta", "index": 0, "text": "The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package." }, + { "type": "block-end", "index": 0, "block": { "type": "reasoning", "text": "The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package." } }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 1, "id": "call_00_3K2lg9zGfGsTvuh19xv65220", "name": "bash", "argumentsDelta": "{\"command\": \"python3 -c \\\"\\nimport random\\nfrom pathlib import Path\\npkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json'))\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.Random(0).choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}" }, + { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_00_3K2lg9zGfGsTvuh19xv65220", "name": "bash", "arguments": "{\"command\": \"python3 -c \\\"\\nimport random\\nfrom pathlib import Path\\npkgs = sorted(str(path.parent) for path in Path('packages').glob('*/*/package.json'))\\ncandidates = [p for p in pkgs if p != 'packages/context/session-reference']\\nprint(random.Random(0).choice(candidates))\\n\\\"\", \"description\": \"Re-roll excluding the package from turn 1\"}" } }, + { "type": "usage", "usage": { "inputTokens": 62, "outputTokens": 164, "cacheReadTokens": 9728, "reasoningTokens": 46 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + } + }, { "at": 10, "entry": { From 09d1b0d27ff43687970d7b70049dae7843ce8ae4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 12:50:23 +0800 Subject: [PATCH 183/190] test(web): align skill snapshot with turn actions --- apps/web/tests/snapshots/skill-tool-row/ui.expected.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index 7a51aae904..fc1f23d484 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -7,9 +7,6 @@ - text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy": - img -- 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": - img - img From 0cb922199d6c5341558640bb6f8a19bb1123b2e9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 13:26:40 +0800 Subject: [PATCH 184/190] fix(apiproxy): open config files through Windows on WSL --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 5 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 5 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../host/apiproxy/src/native-path-opener.ts | 45 ++++++++++-- .../apiproxy/tests/native-path-opener.spec.ts | 71 ++++++++++++++++++- 14 files changed, 128 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 8ec7ff129e..b4b1fb5110 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 11554077d1848dcdf59b896dd9c29a39fd2f55d4 -2026-07-30-web-config-plane.zh.md: 527c2de8155a56789358b801f9c374e16c81931b +2026-07-30-web-config-plane.md: 0b18cee414df23a2ed8a8b43b76dc06403804691 +2026-07-30-web-config-plane.zh.md: e70c2a47970f943e49393b099c4fcea58dc0fbdc diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 11554077d1..0b18cee414 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -16,7 +16,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. -**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action. **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 527c2de815..e70c2a4797 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -16,7 +16,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 -**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 9b8d037c43..a702f1511f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c -2026-07-28-tool-call-file-open-in-os.zh.md: 725db61869383711042d85cc1508b00eb1b196b6 +2026-07-28-tool-call-file-open-in-os.md: 73f5091888ab2506eab50b827e74c5120394b127 +2026-07-28-tool-call-file-open-in-os.zh.md: c1bc93c472aae8be8cee3bab6cd02f556abca494 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index a2c9b52507..73f5091888 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -12,12 +12,13 @@ Chat tool rows treated the whole summary line as a click target that opened the File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as hover-underline links with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspacesService.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. -`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, `xdg-open` on Linux. The opener is injectable for tests. URL-only read args (`web_fetch`) are not file links. +`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. ## Alternatives considered - Keep row-click details and add a separate file affordance — rejected; the product ask replaces the row gesture with the file link. - Open files inside an in-app preview — rejected; the ask is the OS default application. +- Treat WSL as desktop Linux — rejected; a WSL process reports `linux`, but a Linux desktop association is optional while its ordinary operator desktop and browser live on Windows. - Reuse `host.pickDirectory`'s timeout exemption — unnecessary; path open hand-off completes quickly under the normal unary deadline. ## Consequences @@ -26,5 +27,5 @@ Clicking a file path in a tool row opens that path on the host. Non-file tool ro ## Risks -- Linux hosts without `xdg-open` fail the RPC; the chat row stays silent while the host returns an internal error. +- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error. - Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index 725db61869..c1bc93c472 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -12,12 +12,13 @@ Status: implemented 文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 ## 考虑过的替代方案 - 保留整行点击打开 details,另加文件入口 — 否决;产品要求用文件链接替换整行手势。 - 在应用内预览文件 — 否决;要求是操作系统默认应用。 +- 将 WSL 当作桌面 Linux — 否决;WSL 进程报告 `linux`,但 Linux 桌面文件关联并非必有,而其常规用户桌面和浏览器位于 Windows 上。 - 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。 ## 后果 @@ -26,5 +27,5 @@ Status: implemented ## 风险 -- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 +- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 9fe338ea47..9c61e62c48 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 29e48d193d24644f37d219b4df44a8fedf062e53 -README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f +README.md: ab27e073dc76335efc619f56365d1705007f7ef2 +README.zh.md: 18bbecf67f51ae63bfacd4ba78437bea95b50bee diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 29e48d193d..ab27e073dc 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. -A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. +A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 17ebc9e8ab..18bbecf67f 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -4,7 +4,7 @@ 设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 -回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 +回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..77fa4afe7d 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 395e0d5085878e230fdf7de49a0ca47745bdc270 +README.zh.md: 2ef34f7d6e7ae031dd5f847dfa13827fe4550839 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..395e0d5085 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -32,7 +32,7 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). WSL translates the Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item` instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..2ef34f7d6e 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -32,7 +32,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。WSL 会通过 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index a9fdd56bc4..fa7ae5d081 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,5 +1,6 @@ /** Cross-platform native path and text-document openers used by the local GUI carrier. */ +import { release as osRelease } from 'node:os' import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ @@ -8,6 +9,10 @@ export type PathOpenerRunner = NativeCommandRunner /** Injectable platform facts for deterministic adapter tests. */ export interface PathOpenerInternals { platform?: NodeJS.Platform + /** Kernel release override used to distinguish WSL from desktop Linux. */ + osRelease?: string + /** WSL environment marker override used with the kernel release. */ + env?: Readonly<Partial<Record<'WSL_DISTRO_NAME' | 'WSL_INTEROP', string>>> run?: PathOpenerRunner } @@ -19,6 +24,36 @@ function powershellLiteral(path: string): string { return `'${path.replace(/'/g, "''")}'` } +/** Whether one environment marker is set to a non-empty value. */ +function present(value: string | undefined): boolean { + return value !== undefined && value !== '' +} + +/** Distinguish WSL from desktop Linux using its process and kernel markers. */ +function isWsl(internals: PathOpenerInternals): boolean { + const env = internals.env ?? process.env + if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true + return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') +} + +/** Open one Windows-resolvable path through its registered desktop application. */ +async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> { + await run('powershell.exe', [ + '-NoProfile', + '-Command', + `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, + ], signal) +} + +/** Translate a WSL path before handing it to the Windows desktop. */ +async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> { + const translated = await run('wslpath', ['-w', path], signal) + signal.throwIfAborted() + const windowsPath = translated.stdout.replace(/[\r\n]+$/, '') + if (windowsPath === '') throw new Error('wslpath returned no Windows path') + await openWindowsPath(windowsPath, signal, run) +} + /** Dispatch one shell-free platform command for the requested open intent. */ async function openNativePathWithIntent( path: string, @@ -35,15 +70,15 @@ async function openNativePathWithIntent( } if (platform === 'win32') { - await run('powershell.exe', [ - '-NoProfile', - '-Command', - `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, - ], signal) + await openWindowsPath(path, signal, run) return } if (platform === 'linux') { + if (isWsl(internals)) { + await openWslPath(path, signal, run) + return + } await run('xdg-open', [path], signal) return } diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index 236de1c9a7..0c6c327273 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -14,6 +14,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() vi.mock('node:child_process', () => ({ execFile: execFileMock })) +import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' @@ -34,10 +35,58 @@ describe('native path opener', () => { it('uses the Linux desktop association for text documents', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) - await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run }) + await openNativeTextFile('/tmp/settings.yaml', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', env: {}, run, + }) expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal)) }) + it.each([ + ['distribution marker', { WSL_DISTRO_NAME: 'Ubuntu' }, '6.8.0-generic'], + ['interop marker', { WSL_INTEROP: '/run/WSL/123_interop' }, '6.8.0-generic'], + ['kernel release', {}, '5.15.153.1-microsoft-standard-WSL2'], + ])('hands WSL text documents to the Windows desktop from the %s', async (_label, env, osRelease) => { + const requestSignal = signal() + const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath' + ? { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml\r\n', stderr: '' } + : { stdout: '', stderr: '' }) + await openNativeTextFile('/home/test user/settings.yaml', requestSignal, { + platform: 'linux', osRelease, env, run, + }) + expect(run.mock.calls).toEqual([ + ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal], + [ + 'powershell.exe', + [ + '-NoProfile', + '-Command', + "Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'", + ], + requestSignal, + ], + ]) + }) + + it('rejects an empty WSL path translation before invoking Windows', async () => { + const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '\r\n', stderr: '' })) + await expect(openNativeTextFile('/home/test/settings.yaml', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run, + })).rejects.toThrow('wslpath returned no Windows path') + expect(run).toHaveBeenCalledOnce() + }) + + it('does not invoke Windows when the request aborts during WSL path translation', async () => { + const abort = new AbortController() + const run = vi.fn<PathOpenerRunner>(async () => { + abort.abort(new Error('closed')) + return { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test\\settings.yaml\n', stderr: '' } + }) + await expect(openNativeTextFile('/home/test/settings.yaml', abort.signal, { + platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run, + })).rejects.toThrow('closed') + expect(run).toHaveBeenCalledOnce() + }) + it('opens with Windows Invoke-Item and escapes single quotes', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run }) @@ -60,7 +109,10 @@ describe('native path opener', () => { it('opens with Linux xdg-open', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) - await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run }) + await openNativePath('/tmp/a.txt', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', + env: { WSL_DISTRO_NAME: '', WSL_INTEROP: '' }, run, + }) expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal)) }) @@ -71,7 +123,9 @@ describe('native path opener', () => { it('uses the current process platform when no platform override is supplied', async () => { const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' })) - await openNativePath('/tmp/platform-default.txt', signal(), { run }) + await openNativePath('/tmp/platform-default.txt', signal(), { + osRelease: '6.8.0-generic', env: {}, run, + }) const expected = process.platform === 'win32' ? 'powershell.exe' : process.platform === 'linux' @@ -80,6 +134,17 @@ describe('native path opener', () => { expect(run.mock.calls[0]?.[0]).toBe(expected) }) + it('samples ambient WSL markers and kernel release when no fact overrides are supplied', async () => { + const ambientWsl = [process.env.WSL_DISTRO_NAME, process.env.WSL_INTEROP] + .some(value => value !== undefined && value !== '') + || osRelease().toLowerCase().includes('microsoft') + const run = vi.fn<PathOpenerRunner>(async command => command === 'wslpath' + ? { stdout: 'C:\\settings.yaml\n', stderr: '' } + : { stdout: '', stderr: '' }) + await openNativePath('/tmp/ambient-facts.yaml', signal(), { platform: 'linux', run }) + expect(run.mock.calls[0]?.[0]).toBe(ambientWsl ? 'wslpath' : 'xdg-open') + }) + it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { callback(null, '', '') From 1f853d85cd33fd91c56e34c98831adbb48f5410d Mon Sep 17 00:00:00 2001 From: fz <fz@dsh.dev> Date: Thu, 6 Aug 2026 11:09:09 +0800 Subject: [PATCH 185/190] Attribute reader scroll input through the observed-top ledger ChatView's bottom-follow recognized only wheel gestures as reader input, so touch panning, scrollbar dragging, and keyboard paging could not leave the bottom of a streaming transcript. Replace the wheel listener with device-agnostic attribution: a scroll position deviating from the observed-top ledger of the last delivered or written scrollTop is reader input. Adds keyboard-paging and touch-style fling e2e scenarios (red under the old implementation) and the bilingual Agent Note triplet. --- ...cky-composer-conversation-scroll.i18n.yaml | 4 +- ...-29-sticky-composer-conversation-scroll.md | 6 +- ...-sticky-composer-conversation-scroll.zh.md | 6 +- ...-attribution-observed-top-ledger.i18n.yaml | 6 + ...-scroll-attribution-observed-top-ledger.md | 37 +++++ ...roll-attribution-observed-top-ledger.zh.md | 37 +++++ ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- apps/web/tests/chat-scroll-contract.e2e.ts | 145 ++++++++++++++++++ .../src/client/chat/ChatView.tsx | 45 ++---- .../ui-conversation/tests/chat-view.spec.tsx | 22 +-- 12 files changed, 262 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml index b849211296..fdb487f16b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md -2026-07-29-sticky-composer-conversation-scroll.md: d3fed7a9d0b1f39f9551fbd85e0f83515b1a2690 -2026-07-29-sticky-composer-conversation-scroll.zh.md: 2beee34d3bb68832d14b7607b43aa11e1425d53d +2026-07-29-sticky-composer-conversation-scroll.md: 8657080078e475d6e90f17a6c0f8cb5b2fb1555d +2026-07-29-sticky-composer-conversation-scroll.zh.md: a588903adf51c6fcdd01f851d4f5e6570f695242 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md index d3fed7a9d0..8657080078 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -14,7 +14,7 @@ The active conversation column split scrolling: the chat (and trajectory) view o Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host. -Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry. A passive wheel listener takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering the event; the current non-negative floor excludes a concurrent layout clamp from reader movement. A scroll without matching wheel movement re-pins while following and only refreshes the semantic position while reading. ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write. +Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry; how reader input is recognized — device-agnostic deviation from the observed-top ledger of the last delivered or written `scrollTop` — is owned by the [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md). ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write. ## Alternatives considered @@ -26,8 +26,8 @@ Chat history prepend follows reader intent through stable rendered node/call ide **Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned. -**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership remain outside the provenance model instead of adding a general input state machine. +**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership were left outside the provenance model instead of adding a general input state machine. The [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) later closed this deferral by generalizing attribution through the observed-top ledger, still without an input state machine. ## Consequences -Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft. +Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve reader scroll decisions, including Chromium's compositor-first delivery and stream-finalization shrink clamp. Follow ownership extends to every reader input under the [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md). No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md index 2beee34d3b..a588903adf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -14,7 +14,7 @@ Status: implemented 会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。 -Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态。passive wheel 监听器以最近一次由主线程交付或由程序写入的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进合成器几何状态,之后才交付事件;当前使用的非负下限不会将并发的布局钳制计入读者移动。没有对应滚轮/触控板输入位移的滚动,在跟随状态下会重新贴底,在阅读状态下则只刷新语义位置。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。 +Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态;读者输入如何被识别——即以与设备无关的方式偏离由最近一次交付或写入的 `scrollTop` 构成的 observed-top ledger——由[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)负责。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。 ## Alternatives considered @@ -26,8 +26,8 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图 **把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。 -**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权仍不纳入输入来源模型,也不为此新增通用输入状态机。 +**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权当时被留在输入来源模型之外,也未为此新增通用输入状态机。[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)后来通过 observed-top ledger 泛化了归因,补上了这一延后事项,且仍未引入输入状态机。 ## Consequences -在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。 +在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留读者的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段的收缩钳制。贴底跟随所有权依据[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)扩展到每一种读者输入。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml new file mode 100644 index 0000000000..1c861833cf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md +2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 2dcd0178e4216d4d3e2edcd3bfcc05b63606c333 +2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: ffb03fdb68c9cb040e311077dd698b310a033fdd diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md new file mode 100644 index 0000000000..2dcd0178e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md @@ -0,0 +1,37 @@ +# Agent Note: Reader scroll attribution through the observed-top ledger + +Status: implemented + +English | [中文](2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md) + +## Problem + +ChatView's bottom-follow recognized only wheel/trackpad gestures as reader input: while pinned to the floor, a scroll event without matching wheel movement was treated as programmatic and snapped back. Touch panning, native-scrollbar dragging, and keyboard paging therefore could not leave the bottom of a streaming transcript — on a phone the tail was effectively locked. That wheel-only provenance was a deliberate deferral in the [sticky-composer note](2026-07-29-sticky-composer-conversation-scroll.md), which rejected a general input state machine "for this narrow fix" and left every other scroll source outside the model. + +## Decision + +Reader input is no longer identified by device. ChatView keeps an observed-top ledger (`observedTopRef`): the last `scrollTop` either delivered on the main thread or written by the component, recorded synchronously at every programmatic write site — bottom follow, open restore, prepend anchoring, resize follow, and scroll delivery itself. When a scroll event arrives, a position that deviates from `min(ledger, floor)` by more than half a pixel is reader input; a position on the ledger (a delayed programmatic delivery) or exactly on the shrunken floor (a browser clamp after content shrank) preserves the current ownership state. Ownership then changes only through reader input under the existing threshold rule: within `FOLLOW_THRESHOLD` of the floor re-pins, beyond it releases follow and shows Back to bottom. The wheel listener and its epoch bookkeeping are deleted; the component listens to `scroll` alone, so wheel, touch, scrollbar, keyboard, and any future input source are covered by one rule. + +## Contract change: coalesced shrink-plus-regrow clamps + +A shrink clamp whose layout regrows within the same rendering update before the clamp's scroll event is delivered is geometrically indistinguishable from reader input, so it now reads as the reader and releases follow (Back to bottom recovers). Realistic React-commit-driven shrink and regrow is still absorbed: the layout-effect follow re-pins and re-records the ledger per commit, and a shrink-only clamp lands exactly on `min(ledger, floor)`. Only a non-React reflow that shrinks and regrows inside one update mis-attributes. The previous wheel model kept following in that raced case; the unit contract was rewritten to the absorbed-shrink-only guarantee in the same change. + +## Testing + +Unit specs in `packages/client/ui-conversation/tests/chat-view.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. + +The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly. + +## Alternatives considered + +**Keep the wheel-only model.** Rejected: it is the defect. Touch, scrollbar, and keyboard readers cannot take ownership away from a streaming tail, and each newly supported device would need its own carve-out. + +**Enumerate input devices.** Adding `touchstart`/`pointerdown`/`keydown` listeners beside the wheel epoch was the obvious extension. Rejected: native-scrollbar dragging exposes no input event to latch before its scrolls arrive, device lists rot as browsers add sources, and every listener would need its own compositor-delivery grace window — the input state machine the sticky-composer note already declined to build. + +**Absorb the coalesced shrink-plus-regrow clamp with heuristics.** Floor-mismatch grace windows or deferred rAF re-checks could keep the raced clamp from reading as the reader. Rejected: streaming rewrites the floor at chunk pace (24 ms) against ~16 ms frames, so any grace window either swallows genuine touch input during streaming — reopening the bug this change fixes — or is too short to cover the race it targets. The mis-attribution is accepted and recoverable instead. + +**Drive real touch and scrollbar devices in e2e.** Rejected by the environment, not by preference: every synthesis path (CDP touch gestures, touch event sequences, synthetic mouse on classic scrollbars, headed under Xvfb) was probed and cannot scroll; the details live in Testing above. + +## Consequences + +Every reader input owns bottom-follow uniformly, with less code: the wheel listener, its epoch counter, and the pre-input baseline bookkeeping are gone, and attribution rides state the component already maintained. The sticky-composer note's layout, wheel chaining, and prepend-anchoring decisions are untouched and remain authoritative; its narrow provenance rule is superseded by this note. The cost is the contract change above — a coalesced non-React shrink-plus-regrow clamp now pauses follow until the reader returns to the floor or presses Back to bottom — traded for touch, scrollbar, and keyboard correctness during streaming. The e2e lane gains non-wheel coverage only within what its browser can synthesize; if gesture synthesis starts working in a future Chromium, the fling emulation can be replaced by real touch strokes without changing the asserted contract. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md new file mode 100644 index 0000000000..ffb03fdb68 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 经由 observed-top ledger 的读者滚动归因 + +Status: implemented + +[English](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) | 中文 + +## 问题 + +ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输入:钉在底部(floor)期间,一个没有对应滚轮位移的滚动事件会被视为程序化滚动并被拉回底部。因此触控平移、拖动原生滚动条与键盘翻页都无法离开流式 transcript(文本记录)的底部,在手机上尾部实际上被锁死。这种仅认滚轮的输入来源判定是 [sticky-composer 笔记](2026-07-29-sticky-composer-conversation-scroll.md)中有意的暂缓:该笔记拒绝为「此次窄范围修复」建立通用输入状态机,把其余所有滚动来源都留在模型之外。 + +## 决策 + +读者输入不再依据设备来识别。ChatView 维护一份 observed-top ledger(`observedTopRef`):即最近一次由主线程交付、或由组件自身写入的 `scrollTop`,并在每一个程序化写入点(贴底跟随、打开时恢复、前置锚定、尺寸变化跟随以及滚动交付本身)同步记录。滚动事件到达时,偏离 `min(ledger, floor)` 超过半像素的位置即为读者输入;落在 ledger 上的位置(迟到的程序化交付),或恰好落在收缩后底部上的位置(内容收缩后的浏览器钳制),则维持当前的所有权状态。此后所有权只经由读者输入、按既有阈值规则变化:位置距底部在 `FOLLOW_THRESHOLD` 以内则重新贴底,超出则释放跟随并显示「回到底部」。滚轮监听器及其 epoch 簿记已删除;组件只监听 `scroll`,因此滚轮、触控、滚动条、键盘以及未来任何输入来源都由同一条规则覆盖。 + +## 契约变更:收缩与重新增长被合并的钳制 + +如果一次收缩钳制的布局在同一次渲染更新内、赶在该钳制的滚动事件交付之前重新增长,那么这个事件在几何上与读者输入无法区分,因此它现在会被判读为读者并释放跟随(可经「回到底部」恢复)。现实中由 React 提交(commit)驱动的收缩与重新增长仍会被吸收:layout effect 中的跟随会在每次提交后重新贴底并重新记录 ledger,而仅收缩的钳制会恰好落在 `min(ledger, floor)` 上。只有在同一次更新内先收缩再重新增长的非 React 重排会被误归因。旧的滚轮模型在这种竞态情形下会保持跟随;单元测试契约已在同一变更中改写为只吸收纯收缩的保证。 + +## 测试 + +`packages/client/ui-conversation/tests/chat-view.spec.tsx` 中的单元测试直接钉住 ledger 契约:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 + +该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。 + +## 曾考虑的替代方案 + +**保留仅认滚轮的模型。** 否决:它本身就是缺陷所在。触控、滚动条与键盘读者无法从流式尾部夺走所有权,而每种新支持的设备都需要单独开一个特例。 + +**逐一枚举输入设备。** 在滚轮 epoch 旁边加挂 `touchstart`/`pointerdown`/`keydown` 监听器是最显而易见的扩展。否决:拖动原生滚动条在其滚动事件到达之前不暴露任何可供锁存的输入事件;设备清单会随浏览器新增输入来源而逐渐腐化;而且每个监听器都需要自己的合成器交付宽限窗口——这正是 sticky-composer 笔记当初就拒绝构建的那个输入状态机。 + +**用启发式吸收收缩与重新增长被合并的钳制。** 针对底部失配的宽限窗口,或推迟到 rAF 的复查,本可让这种竞态下的钳制不被判读为读者。否决:流式输出以分片节奏(24 ms)改写底部,而帧间隔约 16 ms,因此任何宽限窗口要么会在流式输出期间吞掉真实的触控输入(重新打开本次变更所修复的缺陷),要么短到盖不住它想针对的竞态。转而接受这一误归因,它是可恢复的。 + +**在 e2e 中驱动真实的触控与滚动条设备。** 否决来自环境,而非偏好取舍:每条合成路径(CDP 触控手势、触控事件序列、经典滚动条上的合成鼠标、Xvfb 下的有头模式)都逐一试过,均无法滚动;细节见上文「测试」一节。 + +## 后果 + +每种读者输入现在都以同一方式拥有贴底跟随,而代码更少:滚轮监听器、它的 epoch 计数器以及输入前基线簿记均已移除,归因搭载在组件本就维护的状态之上。sticky-composer 笔记中的布局、滚轮链式处理与前置锚定决策原样保留,仍为权威;其窄范围的输入来源规则由本笔记取代。代价就是上文的契约变更:一次收缩与重新增长被合并的非 React 钳制现在会暂停跟随,直到读者回到底部或按下「回到底部」;以此换来流式输出期间触控、滚动条与键盘的正确性。e2e 车道获得的非滚轮覆盖仅限其浏览器能够合成的范围;若手势合成在未来某个 Chromium 版本中开始可用,可以在不改变所断言契约的前提下,把快滑模拟替换为真实的触控划动。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 638e91a016..5f9a220dc5 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f +2026-07-24-web-gui-browser-e2e-lane.md: 095ad9055c8462ecec67609ed9af55df2a2d7adb +2026-07-24-web-gui-browser-e2e-lane.zh.md: eb20ee378eb5154bafcb7a448202c66dacda9199 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index f8519a9622..095ad9055c 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -42,7 +42,7 @@ The typecheck plane split is structural: the host scaffold, its support module, ### Coverage contract -The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. The required lane additionally carries an 88-turn synthetic Chat scroll contract mixing wrapped Markdown, fenced code, and paired bash calls/results. Real wheel, composer, tool, tab, session, and viewport interactions assert a named settled row's top relative to the transcript scrollport and distance from the true bottom across concurrent history prepend plus paced streaming, pinned/away streaming, tool-disclosure offscreen cycles, expanded-history view/session remount, width reflow, immediate pinned remount, composer resize, and textarea wheel chaining; it deliberately pins neither DOM cardinality nor absolute `scrollTop`, so the same contract can qualify a virtualized implementation. A separate interaction contract over the same fixture pins heterogeneous-row order, independent adjacent tool disclosure, exact user-message clipboard content, a turn-bounded message fork, source/child isolation, and a real follow-up turn in the child; wheel input only navigates to semantic targets and carries no geometry expectation. A short live-history contract starts from a blank workspace and drives consecutive composer turns, including real bash call/result rounds and a paced long final response, pinning one session identity, exact per-turn event ownership, browser echo uniqueness, and composer recovery without timing thresholds. +The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. The required lane additionally carries an 88-turn synthetic Chat scroll contract mixing wrapped Markdown, fenced code, and paired bash calls/results. Real wheel, composer, tool, tab, session, and viewport interactions assert a named settled row's top relative to the transcript scrollport and distance from the true bottom across concurrent history prepend plus paced streaming, pinned/away streaming, tool-disclosure offscreen cycles, expanded-history view/session remount, width reflow, immediate pinned remount, composer resize, and textarea wheel chaining; real keyboard paging and a touch-style fling emulation additionally pin wheel-free bottom-follow ownership ([reader-scroll-attribution note](../bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md)); it deliberately pins neither DOM cardinality nor absolute `scrollTop`, so the same contract can qualify a virtualized implementation. A separate interaction contract over the same fixture pins heterogeneous-row order, independent adjacent tool disclosure, exact user-message clipboard content, a turn-bounded message fork, source/child isolation, and a real follow-up turn in the child; wheel input only navigates to semantic targets and carries no geometry expectation. A short live-history contract starts from a blank workspace and drives consecutive composer turns, including real bash call/result rounds and a paced long final response, pinning one session identity, exact per-turn event ownership, browser echo uniqueness, and composer recovery without timing thresholds. ### CI stance diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 294f3e840e..eb20ee378e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -42,7 +42,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 覆盖契约 -该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动契约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一契约可以验收虚拟化实现。另一份基于同一 fixture 的交互契约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离,以及子会话中的一次真实追问轮次;wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史契约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。 +该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动契约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;真实键盘翻页与触摸式惯性滑动模拟额外钉住不依赖 wheel 的贴底跟随所有权([读者滚动归因笔记](../bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md));它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一契约可以验收虚拟化实现。另一份基于同一 fixture 的交互契约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离,以及子会话中的一次真实追问轮次;wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史契约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。 ### CI 立场 diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 6509055036..0c75afb4e7 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -40,6 +40,11 @@ const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST' const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE' const TOOL_READY_FILE = '.chat-scroll-tool-ready' const TOOL_RELEASE_FILE = '.chat-scroll-tool-release' +const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e' +const FLING_SESSION_ID = 'chat-scroll-fling-e2e' +const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.' +const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST' +const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE' const HISTORY_FIXTURE = createChatScrollFixture({ markerPrefix: 'HISTORY', @@ -58,6 +63,10 @@ const RESTORE_FIXTURE_B = createChatScrollFixture({ title: 'CHAT_SCROLL_RESTORE_B comparison session', turns: 32, }) +const INPUTS_FIXTURE = createChatScrollFixture({ + markerPrefix: 'INPUTS', + title: 'CHAT_SCROLL_INPUTS non-wheel reader input session', +}) interface ScrollGeometry { readonly distanceFromBottom: number @@ -273,6 +282,34 @@ async function wheelTranscript(page: Page, deltaY: number): Promise<void> { await nextPaint(page) } +/** + * Touch-style momentum fling over the transcript. Headless Chromium in the + * test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture + * and Input.dispatchTouchEvent both deliver DOM events without moving any + * scroller, and compositor scrollbars ignore synthetic mouse input), so the + * fling replays the signature a real pan leaves on the scrollport: per-frame + * decaying displacements the component never authored, carrying no wheel + * events. Wheel-sign semantics: positive deltaY reads downward. + */ +async function flingTranscript(page: Page, deltaY: number): Promise<void> { + await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => { + const direction = Math.sign(delta) + let remaining = Math.abs(delta) + // Fast launch decaying toward a floor speed, like a released finger. The + // floor stays above the follow threshold so contended frames (streaming + // writes racing the fling) still deviate far enough to read as input. + let velocity = Math.max(120, remaining / 8) + while (remaining > 0) { + const step = Math.min(velocity, remaining) + host.scrollTop += direction * step + remaining -= step + velocity = Math.max(48, velocity * 0.9) + await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() })) + } + }, deltaY) + await nextPaint(page) +} + async function wheelToHistoryStart(page: Page): Promise<void> { for (let attempt = 0; attempt < 12; attempt += 1) { if ((await scrollGeometry(page)).scrollTop <= 1) break @@ -683,4 +720,112 @@ describe('web e2e: long Chat scroll contract', () => { assertClean(world) }) }, 180_000) + + // Keyboard is the only non-wheel device this lane's Chromium can drive for + // real (see flingTranscript for the probe results on touch and scrollbars), + // so it stands in for the whole hardware input pipeline here. + it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => { + await withScrollWorld({ + failureShot: 'web-e2e-chat-scroll-keyboard', + seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }], + }, async (world) => { + await openSeed( + world.page, + INPUTS_FIXTURE, + INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns), + ) + await expectBottom(world.page) + const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) + + // Focus rides the last seeded tool row (a tabbable button whose keydown + // handler passes scrolling keys through). End first normalizes the + // focus-driven scrollIntoView back to the floor. + const lastToolRow = world.page.locator( + `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`, + ) + await lastToolRow.focus() + await world.page.keyboard.press('End') + await expectBottom(world.page) + await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) + for (let press = 0; press < 3; press += 1) { + await world.page.keyboard.press('PageUp') + await nextPaint(world.page) + } + await backToBottom.waitFor({ timeout: 10_000 }) + await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 }) + .toBeGreaterThan(100) + await world.page.keyboard.press('End') + await expectBottom(world.page) + await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) + assertClean(world) + }) + }, 180_000) + + it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => { + await withScrollWorld({ + failureShot: 'web-e2e-chat-scroll-fling-stream', + replay: [ + replayEntry(toolStream()), + replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)), + ], + seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_SESSION_ID }], + }, async (world) => { + const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE) + const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE) + await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns)) + const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) + const settled = world.scaffold.whenTurnSettled(60_000) + let released = false + try { + const composer = world.page.locator('textarea:enabled').last() + await composer.fill(LIVE_FLING_PROMPT) + await world.page.getByRole('button', { name: 'Send message', exact: true }).click() + await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true) + await expectBottom(world.page) + + // Fling away while the turn is mid-flight: the scroll burst alone must + // release bottom ownership, exactly like a wheel scroll would, even + // while streaming keeps re-asserting the floor between frames. + await flingTranscript(world.page, -900) + await backToBottom.waitFor({ timeout: 10_000 }) + const awayAnchor = await visibleFlowAnchor(world.page) + const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length + await writeFile(releasePath, 'release\n') + released = true + await expect.poll( + () => world.events.some(event => event.type === 'tool/result'), + { timeout: 15_000 }, + ).toBe(true) + await expect.poll( + () => world.events.filter(event => event.type === 'assistant/chunk').length, + { timeout: 15_000 }, + ).toBeGreaterThan(chunksBeforeRelease + 5) + await expectSameFlowTop(world.page, awayAnchor) + + // Fling back to the floor: re-pin must come from the reader's scroll + // itself, and follow must then own the still-streaming tail. The + // retry loop chases the floor that streaming keeps pushing down. + for (let attempt = 0; attempt < 8; attempt += 1) { + if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break + await flingTranscript(world.page, 1_600) + } + await expectBottom(world.page) + await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) + const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length + await expect.poll( + () => world.events.filter(event => event.type === 'assistant/chunk').length, + { timeout: 15_000 }, + ).toBeGreaterThan(chunksAtRepin + 5) + await expectBottom(world.page) + } finally { + if (!released) await writeFile(releasePath, 'release\n').catch(() => {}) + } + + await settled + await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) + await world.page.getByText(LIVE_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) + await expectBottom(world.page) + assertClean(world) + }) + }, 180_000) }) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 6058efca98..09433e4085 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -372,9 +372,6 @@ export function ChatView({ const [atBottom, setAtBottom] = useState(true) /** Last position delivered or written on the main thread. */ const observedTopRef = useRef(0) - /** Pre-input position for the current wheel gesture. */ - const wheelStartRef = useRef<number | null>(null) - const wheelEpochRef = useRef(0) /** Paging anchor: semantic row/position at click, updated by reader scrolls * while the request is pending and restored after the prepend lands. */ const anchorRef = useRef<PagingAnchor | null>(null) @@ -394,8 +391,6 @@ export function ChatView({ const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { - wheelStartRef.current = null - wheelEpochRef.current += 1 anchorRef.current = null el.scrollTop = el.scrollHeight observedTopRef.current = el.scrollTop @@ -472,17 +467,19 @@ export function ChatView({ /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */ if (local === null) return const el = scrollerOf(local) - // Only wheel input may make raw scroll geometry change follow ownership. - // Browser clamping and delayed programmatic scroll events otherwise have - // the same event shape and must preserve the current ownership state. + // Only reader input may make raw scroll geometry change follow ownership: + // a delivered position that deviates from the observed-top ledger (every + // programmatic write records itself there synchronously). This covers + // wheel, touch, scrollbar, and keyboard alike without naming devices. + // Browser shrink-clamps land exactly on the floor min and delayed + // programmatic deliveries land on the ledger itself, so both preserve + // the current ownership state. const floor = Math.max(0, el.scrollHeight - el.clientHeight) - const wheelStart = wheelStartRef.current - const movedByWheel = wheelStart !== null - && Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5 - const isAtBottom = movedByWheel + const movedByReader = Math.abs(el.scrollTop - Math.min(observedTopRef.current, floor)) > 0.5 + const isAtBottom = movedByReader ? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1 : atBottomRef.current - if (!movedByWheel && isAtBottom) { + if (!movedByReader && isAtBottom) { toBottom(el) return } @@ -501,34 +498,18 @@ export function ChatView({ observedTopRef.current = el.scrollTop } - // Bind scroll and the wheel provenance needed to distinguish reader input - // from layout-driven scrolls on the resolved scrollport once per mount. + // Bind the scroll listener on the resolved scrollport once per mount; + // reader-input attribution rides the observed-top ledger, not per-device + // input listeners. useEffect(() => { const local = listRef.current /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ if (local === null) return const el = scrollerOf(local) const onScroll = (): void => { onScrollRef.current() } - const onWheel = (event: WheelEvent): void => { - if (event.ctrlKey || event.deltaY === 0) return - const startTop = observedTopRef.current - const floor = Math.max(0, el.scrollHeight - el.clientHeight) - const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1 - if (!canMove) return - wheelStartRef.current = startTop - const epoch = ++wheelEpochRef.current - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (wheelEpochRef.current === epoch) wheelStartRef.current = null - }) - }) - } el.addEventListener('scroll', onScroll, { passive: true }) - el.addEventListener('wheel', onWheel, { capture: true, passive: true }) return () => { - wheelStartRef.current = null el.removeEventListener('scroll', onScroll) - el.removeEventListener('wheel', onWheel, true) } }, []) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 2369e8f1d6..ae0340f8a4 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -158,9 +158,9 @@ function makeHarness(init?: Partial<ConversationSnapshot>) { return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection } } -/** Simulate reader input before the browser delivers the host scroll event. */ +/** Simulate reader input (any device): a delivered position that deviates + * from the observed-top ledger of programmatic writes. */ function readerScroll(element: HTMLElement, top: number): void { - fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 }) element.scrollTop = top fireEvent.scroll(element) } @@ -939,7 +939,7 @@ describe('ChatView', () => { expect(view.queryByLabelText('回到底部')).toBeNull() }) - it('keeps following when a delayed clamp scroll arrives after layout regrows', () => { + it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render(<h.ChatView {...h.props} />) const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement @@ -947,12 +947,12 @@ describe('ChatView', () => { scroller.scrollTop = 700 fireEvent.scroll(scroller) - // The wheel cannot move farther down. A stream-finalization shrink clamps - // the old position, then reflow grows the layout before scroll delivery. - fireEvent.wheel(scroller, { deltaY: 120 }) - metrics.setLayout(1_040, 500) + // Stream finalization shrinks the column: the browser clamps the pinned + // position onto the new floor and delivers a scroll event. The clamp + // lands exactly on the ledger's floor min, so it is not reader input. + metrics.setLayout(800, 700) fireEvent.scroll(scroller) - expect(scroller.scrollTop).toBe(740) + expect(scroller.scrollTop).toBe(500) expect(view.queryByLabelText('回到底部')).toBeNull() expect(h.chatScroll.read()).toBeNull() @@ -961,7 +961,7 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(900) }) - it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => { + it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render(<h.ChatView {...h.props} />) const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement @@ -969,8 +969,10 @@ describe('ChatView', () => { scroller.scrollTop = 700 fireEvent.scroll(scroller) + // Chromium advances compositor geometry before delivering the event: + // attribution must compare against the observed-top ledger, never a + // baseline sampled from already-moved raw geometry. scroller.scrollTop = 500 - fireEvent.wheel(scroller, { deltaY: -200 }) fireEvent.scroll(scroller) expect(view.getByLabelText('回到底部')).toBeTruthy() }) From 768e2e866fa7681107aff90e055e023c44116957 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 13:56:42 +0800 Subject: [PATCH 186/190] fix(web): keep skill row pairing client-local --- .../2026-08-06-web-skill-tool-row.i18n.yaml | 4 +- .../feature/2026-08-06-web-skill-tool-row.md | 6 +-- .../2026-08-06-web-skill-tool-row.zh.md | 6 +-- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 43 ++++++--------- .../client/connection/src/client/index.ts | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../client/session-history/history-fold.ts | 16 ++---- .../src/client/sessions/conversation.ts | 6 +-- .../runtime/src/client/sessions/session.ts | 12 ++--- .../src/client/sessions/transcript-adapter.ts | 26 ++------- packages/client/runtime/tests/fake-api.ts | 4 +- .../client/runtime/tests/history-fold.spec.ts | 14 ----- packages/client/runtime/tests/session.spec.ts | 17 ------ .../runtime/tests/transcript-adapter.spec.ts | 16 ------ .../client/contract/terminal-card-model.ts | 11 ++-- .../ui-conversation/tests/chat-view.spec.tsx | 4 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 3 +- packages/client/ui-skill/README.zh.md | 3 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 54 ++++--------------- packages/host/apiproxy/src/api/index.ts | 2 +- .../host/apiproxy/src/api/sessions.schema.ts | 12 +---- packages/host/apiproxy/src/api/sessions.ts | 18 ++----- .../apiproxy/tests/api-proxy-view.spec.ts | 41 -------------- .../host/apiproxy/tests/rpc-schemas.spec.ts | 10 +--- 31 files changed, 79 insertions(+), 273 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml index 237338a7e6..a9ee64e640 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-web-skill-tool-row.md -2026-08-06-web-skill-tool-row.md: bebcf658de33d133ffea8eb190fb4e8e63bf82ff -2026-08-06-web-skill-tool-row.zh.md: 9377829aab1cb6b347cb837dafe7e7e4afb63868 +2026-08-06-web-skill-tool-row.md: 6583062f38b0e9cff059fa4477313ff6a5bdd2aa +2026-08-06-web-skill-tool-row.zh.md: 3d5c4b712896c2cf41df3ec913c597f7f791486c diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md index bebcf658de..6583062f38 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -14,16 +14,16 @@ The Web transcript renders `skill` calls through the generic fallback row, so a The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. -The row derives every visible value from the logged call/result slice. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. Because a history page can contain a `tool/result` after its `tool/call` fell outside the window, the generic `HistoryEntry` envelope now carries the paired call's name, exact arguments JSON, and event time on result entries. The Host derives this transient annotation and the result render intent from the complete log; the runtime prefers an in-window call and otherwise materializes the same `ToolResultNode.call` and `callTime` from the annotation. An orphan result still has `call: null`, and a call-side render intent remains unavailable when its event is outside the page. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. +The row derives every visible value from a paired call/result slice in the current runtime window. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. If pagination leaves the call outside the window, the result has no tool identity and remains on the generic fallback rather than extending the history wire contract. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. ## Alternatives considered - Keep the generic tool row and add only a `skill` color selector in `ui-conversation`. This leaves the redundant input envelope and generic expanded body in place, and makes the conversation package own a domain-specific visual rule. -- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool; the cross-page fix belongs to the generic history pairing envelope used by every tool rather than a skill-specific presentation value. +- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool when its call is in the runtime window, so a new cross-boundary presentation value adds protocol and snapshot surface without enabling another consumer. - Export the conversation package's private `ToolRow` component for reuse. Client packages intentionally expose contracts rather than cross-package components; exporting it would couple independent feature packages to conversation implementation details. ## Consequences `ui-skill` now depends on the public conversation toolview contract, locale and primitive packages, and React in addition to its reference-source dependencies. It owns a small copy of the disclosure-row chrome, so future global interaction changes must update this registrant alongside the Bash sample and conversation rows. -Cold replay stays deterministic across pagination and when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. The generic pairing annotation also prevents other keyed tool rows and result presenters from changing identity at a page boundary without persisting duplicate data. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format. +Cold replay stays deterministic when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. A result-only history page intentionally uses the generic fallback; keeping this edge case generic preserves the existing history protocol and confines the feature to client presentation. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md index 9377829aab..3d5c4b7128 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -14,16 +14,16 @@ Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使 收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 -该行的所有可见值均派生自已记录的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。由于 history 页可能包含 `tool/result`,而与之配对的 `tool/call` 已落在窗口外,通用 `HistoryEntry` envelope 现在会在结果条目上携带配对调用的名称、精确的 arguments JSON 和事件时间。Host 从完整日志派生这份瞬时注解和结果渲染意图;runtime 优先使用窗口内调用,否则从该注解物化出相同的 `ToolResultNode.call` 和 `callTime`。无配对结果仍为 `call: null`;调用事件位于页面外时,调用侧渲染意图仍不可用。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 +该行的所有可见值均派生自当前 runtime 窗口中已配对的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。如果分页将调用留在窗口外,结果便没有工具身份,并继续使用通用后备路径,而不是扩展 history 协议契约。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 ## 考虑过的替代方案 - 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。 -- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 已经能够识别该工具;跨页修复属于所有工具共用的通用 history 配对 envelope,而不是 skill 专用的呈现值。 +- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 在调用位于 runtime 窗口内时已经能够识别该工具,因此新的跨边界呈现值只会增加协议和快照表层,却不会支持其他消费方。 - 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。 ## 后果 除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome,因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。 -无论跨越分页,还是已安装的 skill 目录发生变化,冷回放都保持确定性;在用户显式展开指令前,transcript 保持紧凑。通用配对注解还可防止其他键控工具行和结果 presenter 在分页边界改变身份,同时无需持久化重复数据。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 +即使已安装的 skill 目录发生变化,冷回放仍保持确定性;在用户显式展开指令前,transcript 保持紧凑。仅含结果的 history 页有意使用通用后备路径;让这个边缘情况保持通用呈现,可以保留现有 history 协议,并将该功能限定在客户端呈现层。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。 diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index de15a9c67f..6f29b2dda0 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,7 @@ export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, HistoryToolCall, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index bacbacdd23..dc2f8c5967 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -29,7 +29,7 @@ import type { import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { - ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HistoryToolCall, HostFrame, MuxFrame, RpcReceipt, + ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' @@ -664,33 +664,25 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR } } -/** Full-log tool/result pair used by the fixture history envelope and presenter mirror. */ -function pairedHistoryCall(event: SessionEvent, log: readonly SessionEvent[]): HistoryToolCall | undefined { - if (event.type !== 'tool/result') return undefined - const callId = String(event.data.message.source.callId) - for (let i = log.length - 1; i >= 0; i--) { - const candidate = log[i] - /* v8 ignore next -- dense-array guard: i stays within [0, log.length), - so the undefined arm needs a sparse log no code path builds. */ - if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) { - return { name: candidate.data.name, arguments: candidate.data.arguments, time: candidate.time } - } - } - return undefined -} - -/** Host-side viewFor mirror: tool/call presents from its own args; tool/result uses its full-log pair. */ +/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined { if (event.type === 'tool/call') { const view = presentCall(event.data.name, event.data.arguments) return view === undefined ? undefined : { for: 'call', view } } if (event.type === 'tool/result') { - const call = pairedHistoryCall(event, log) - if (call === undefined) return undefined - const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('') - const view = presentResult(call.name, call.arguments, resultText) - return view === undefined ? undefined : { for: 'result', view } + const callId = String(event.data.message.source.callId) + for (let i = log.length - 1; i >= 0; i--) { + const candidate = log[i] + /* v8 ignore next -- dense-array guard: i stays within [0, log.length), + so the undefined arm needs a sparse log no code path builds. */ + if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) { + const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('') + const view = presentResult(candidate.data.name, candidate.data.arguments, resultText) + return view === undefined ? undefined : { for: 'result', view } + } + } + return undefined // cross-page unpaired: documented default } return undefined } @@ -1055,12 +1047,7 @@ function pageOf( } const events = log.slice(start, end).map((event): HistoryEntry => { const view = viewFor(event, log) - const call = pairedHistoryCall(event, log) - return { - event, - ...view === undefined ? {} : { view }, - ...call === undefined ? {} : { call }, - } + return view === undefined ? { event } : { event, view } }) return { events, hasMore: start > 0 } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 83e9722a49..67b47b06c6 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,7 +13,7 @@ import { isLoopbackHostname } from '../loopback-hostname.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, HistoryToolCall, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index ef94a8834c..23c867e4c0 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: 3d981392ce0314f41fe84bc1adb2b9484a6a5989 -README.zh.md: c05bdb6ebb33c0ffa47e2b54fb1b3d9d25f2fa6d +README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27 +README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 3d981392ce..8ac29a4258 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -34,7 +34,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. A paged `tool/result` first pairs against an in-window `tool/call`, then against the Host-carried complete-log call annotation; `ToolResultNode.call` is null only for a truly orphaned durable result, so a page boundary cannot change keyed toolview dispatch, argument-derived labels, or duration. The call-side render intent remains null when its event is outside the window, while the result intent is already computed by the Host from the complete pair. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `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). +`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. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `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). 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 c05bdb6ebb..0e065e43ec 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。分页得到的 `tool/result` 会先与窗口内的 `tool/call` 配对,再与 Host 携带的完整日志调用注解配对;只有持久结果确实没有配对调用时,`ToolResultNode.call` 才为 null,因此分页边界无法改变键控 toolview 分派、由参数派生的标签或耗时。调用事件位于窗口外时,调用侧渲染意图仍为 null,而结果侧渲染意图已经由 Host 基于完整配对计算完成。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`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` 与本程序的冲突)。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`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` 与本程序的冲突)。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 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 83a09d3163..d792fd2b76 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -362,8 +362,7 @@ export function projectConversationHistory( let contextGeneration = 0 for (const [index, event] of events.entries()) { - const entry = entries[index] - const view = entry?.view + const view = entries[index]?.view if (event.type === 'tool/call') { callIndex.set(String(event.data.callId), { name: event.data.name, @@ -371,17 +370,8 @@ export function projectConversationHistory( time: event.time, callView: view?.for === 'call' ? view.view : null, }) - } else if (event.type === 'tool/result') { - const callId = String(event.data.message.source.callId) - if (!callIndex.has(callId) && entry?.call !== undefined) { - callIndex.set(callId, { - name: entry.call.name, - argsRaw: entry.call.arguments, - time: entry.call.time, - callView: null, - }) - } - if (view?.for === 'result') resultViews.set(event.seq, view.view) + } else if (event.type === 'tool/result' && view?.for === 'result') { + resultViews.set(event.seq, view.view) } if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { contextGeneration++ diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 14bd0dc9ed..d24b963d6b 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -155,16 +155,16 @@ export interface TurnErrorNode { code?: string } -/** A tool result paired with its durable call head when the Host can resolve it. */ +/** A tool result paired (when in-window) with its call head. */ export interface ToolResultNode { kind: 'tool-result' seq: number /** Unix epoch ms from the tool/result session event. */ time: number callId: string - /** Call head from the window or history envelope; null only when the durable log has no pair (card head shows callId). */ + /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ call: { name: string; argsRaw: string } | null - /** Unix epoch ms of the paired tool/call; null when the durable log has no pair. */ + /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ callTime: number | null content: readonly ContentBlock[] isError: boolean diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index e663af8bf6..776f4494fd 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - HistoryEntry, HistoryToolCall, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, + HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): @@ -85,8 +85,6 @@ export class Session implements SessionFace { /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view). * Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */ private views: (ToolEventView | undefined)[] = [] - /** Host-carried call metadata aligned with result entries when the call event is outside the page. */ - private historyCalls: (HistoryToolCall | undefined)[] = [] private baseSeq = 0 private hasMore = false private openState: OpenState = 'cold' @@ -383,11 +381,10 @@ export class Session implements SessionFace { } this.events = [...older.map(e => e.event), ...this.events] this.views = [...older.map(e => e.view), ...this.views] - this.historyCalls = [...older.map(e => e.call), ...this.historyCalls] /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ this.baseSeq = older[0]?.event.seq ?? this.baseSeq this.hasMore = result.value.hasMore - this.transcript.reset(this.events, this.views, this.historyCalls) // prepend forces a rebuild (the window grew at the head) + this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head) this.rebuildDerivedFromWindow() } catch (error) { console.error('[web-runtime] loadOlder failed:', error) @@ -414,7 +411,6 @@ export class Session implements SessionFace { this.openError = null this.events = [] this.views = [] - this.historyCalls = [] this.baseSeq = 0 // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. @@ -648,10 +644,9 @@ export class Session implements SessionFace { private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) - this.historyCalls = entries.map(e => e.call) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore - this.transcript.reset(this.events, this.views, this.historyCalls) + this.transcript.reset(this.events, this.views) this.rebuildDerivedFromWindow() if (projections !== undefined) this.projections.seed(projections) const buffered = this.liveBuffer @@ -666,7 +661,6 @@ export class Session implements SessionFace { if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop this.events.push(event) this.views.push(view) - this.historyCalls.push(undefined) this.transcript.append(event, view) this.handoffPendingSteering(event) this.applyEventSideEffects(event, view) diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index b1d952b804..306571b2bf 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -19,9 +19,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' // `sessions: ISessions` (TS2717, the one-program-per-side rule in // docs/development.md). import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -import type { - HistoryToolCall, ToolCallView, ToolEventView, ToolResultView, -} from '@deepseek-ai/dsh-client-connection/client' +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 { contextForm, contextProvenance } from './context-provenance.ts' @@ -215,13 +213,8 @@ export class TranscriptAdapter { * and re-project the transcript. * @param events - the new window contents (seq-ascending). * @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events). - * @param calls - host-carried result pairs aligned with `events` by index. */ - reset( - events: readonly SessionEvent[], - views?: readonly (ToolEventView | undefined)[], - calls?: readonly (HistoryToolCall | undefined)[], - ): void { + reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void { this.rev++ this.eventIndex = new Map() this.callIdx = new Map() @@ -235,7 +228,7 @@ export class TranscriptAdapter { /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ if (event === undefined) continue this.eventIndex.set(event.seq, event) - this.indexCall(event, views?.[i], calls?.[i]) + this.indexCall(event, views?.[i]) this.indexCommand(event) if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) indexAssistantStepTiming(this.stepTimings, event) @@ -345,20 +338,9 @@ export class TranscriptAdapter { return true } - private indexCall(event: SessionEvent, view?: ToolEventView, pairedCall?: HistoryToolCall): void { + private indexCall(event: SessionEvent, view?: ToolEventView): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) - const callId = String(event.data.message.source.callId) - if (!this.callIdx.has(callId) && pairedCall !== undefined) { - this.callIdx.set(callId, { - name: pairedCall.name, - argsRaw: pairedCall.arguments, - turn: event.data.turn, - step: event.data.step, - time: pairedCall.time, - callView: null, - }) - } return } if (event.type !== 'tool/call') return diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b13e27f3c4..e50574d102 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { - ClientResponse, CommandDescriptor, HistoryEntry, HostFrame, IApiClient, ModelTarget, MuxFrame, + ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' @@ -68,7 +68,7 @@ export class FakeApiClient implements IApiClient { onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>> = + => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> = () => Promise.resolve(ok({ events: [], hasMore: false })) onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({ diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index f9b40bdb7c..083bdc3566 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -53,20 +53,6 @@ describe('projectConversationHistory', () => { }]) }) - it('projects a paged tool result from its host-carried call pair', () => { - const result = ev.toolResult(50, 3, 'outside-call', '已加载 skill') - const projection = projectConversationHistory([{ - event: result, - call: { name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }, - }]) - expect(projection.eventNodes).toMatchObject([{ - kind: 'tool-result', - call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, - callTime: 40, - callView: null, - }]) - }) - it('projects a high-sequence history window without synthesizing its unloaded prefix', () => { const baseSeq = 400_000 const events = [ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 02753fe09e..c288c044ee 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -53,23 +53,6 @@ describe('open', () => { expect(snapshot.turnEnds.get(3)).toBe(15) }) - it('installs host-carried call metadata for a result-only tail page', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok({ - events: [{ - event: ev.toolResult(50, 3, 'outside-call', '已加载 skill'), - call: { name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }, - }], - hasMore: true, - })) - await session.open() - expect(session.getSnapshot().nodes).toMatchObject([{ - kind: 'tool-result', - call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, - callTime: 40, - }]) - }) - it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => { const { api, session } = makeSession() await Promise.all([session.open(), session.open()]) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index 99b4cdf261..031acf1780 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -365,22 +365,6 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null }) }) - it('materializes a paged tool-result from its host-carried call pair', () => { - const adapter = new TranscriptAdapter() - adapter.reset( - [ev.toolResult(50, 3, 'outside-call', '已加载 skill')], - [undefined], - [{ name: 'skill', arguments: '{"name":"dsh-code-review"}', time: 40 }], - ) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'tool-result', - callId: 'outside-call', - call: { name: 'skill', argsRaw: '{"name":"dsh-code-review"}' }, - callTime: 40, - callView: null, - }) - }) - it('materializes a tool-result error field when present', () => { const adapter = new TranscriptAdapter() adapter.reset([ diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index b1c4cbe757..8a0c887990 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -168,12 +168,11 @@ function collapse(body: string, rooted: boolean, separator = '/'): string { * returns a generic fenced card for an execution error or a background * start, whose text and error styling the generic path preserves. * - * Window truncation can drop the call event and its call-side view from a - * settled result (see `ToolResultNode.callView` in dsh-client-runtime), leaving - * a terminal result with no presentation call side even though the history - * envelope preserves its name and arguments. That still renders: the command - * falls back to the result view's replacement title, then to an empty command - * (the prompt line draws bare), and the prompt shows no cwd. + * Window truncation can drop the call head from a settled result (see + * `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal + * result with no call side. That still renders: the command falls back to the + * result view's replacement title, then to an empty command (the prompt line + * draws bare), and the prompt shows no cwd. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param sessionCwd - the session workspace root, which resolves an omitted or * relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved. diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f0ed99da14..2369e8f1d6 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -285,12 +285,12 @@ describe('chat-flow derivation', () => { }) describe('ChatView', () => { - it('an orphan tool result renders through the generic fallback', () => { + it('a windowless tool result (call head truncated) renders with an empty tool name', () => { const h = makeHarness({ nodes: [{ ...toolResult(3, 'w1'), call: null }], }) const view = render(<h.ChatView {...h.props} />) - // No durable call exists for this id, so the summary falls back to callId. + // classifyTool('') → others; the summary slot falls back to the callId. expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull() expect(view.getByText('w1')).toBeTruthy() }) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 5c50d22b89..57a1ff1676 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-skill/README.md -README.md: ba9f1faae0f70a0f7bed4641e02703cc26bcb692 -README.zh.md: f8210a885d201cbdc89d7a34704a819e80463d2c +README.md: a9506fe563b94fb4d1f9afd882216e023b0c2d13 +README.zh.md: 6af5d3eb8820dacc2ab569be8b830481dd45fb9a diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index ba9f1faae0..a9506fe563 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -10,7 +10,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou ## Skill tool row -The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the logged call/result slice, using the history envelope's host-carried durable pair when pagination left the call event outside the window; it never reads the current catalog, so cold replay remains stable across page cuts and when installed skills or their descriptions change. +The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change. ## Model Experience @@ -30,6 +30,7 @@ Append-only: the reference is part of a new user message appended after the reus ## Known Limitations and Deferred Work +- **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. - **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change. - **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog. - **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item). diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index f8210a885d..6af5d3eb88 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -10,7 +10,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## skill 工具行 -浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自已记录的调用/结果片段;分页将调用事件留在窗口外时,则使用 history envelope 中由 Host 携带的持久配对。该行绝不读取当前 skill 目录,因此冷回放在跨分页时,以及已安装的 skill 或其描述发生变化时均保持稳定。 +浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 ## 模型体验 @@ -30,6 +30,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 已知限制与暂缓事项 +- **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 - **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 - **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。 - **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f587ca109e..38c79f4617 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: bd8ad485980348a23054d5446d292ef0c24536dd -README.zh.md: 7906ca224930736f335dcbdedc63b6af3019c070 +README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 +README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index bd8ad48598..0963476a76 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. -`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. A `tool/result` entry additionally carries its paired call's name, exact arguments JSON, and event time as a transient history annotation derived from the complete log, so a page cut cannot erase keyed toolview dispatch, argument-derived summaries, or duration. Result render intents use that same complete-log pair; an orphan result or malformed arguments still soft-fall to the generic presentation path. +`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 7906ca2249..e3634c5f92 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,7 +12,7 @@ 首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 -`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。`tool/result` 条目还会携带其配对调用的名称、精确的 arguments JSON 和事件时间,作为从完整日志派生的瞬时 history 注解,因此分页切分无法抹掉键控 toolview 分派、由参数派生的摘要或耗时。结果渲染意图使用完整日志中的同一配对;无配对结果或参数损坏时,仍会软降级到通用呈现路径。 +`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 998e8d3ac5..19fb0fe8a2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -26,7 +26,7 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, CredentialView, GoalRef, HistoryEntry, HistoryToolCall, HostFrame, ModelCatalogFailure, ModelProviderGroup, + ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, @@ -408,9 +408,9 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues * Compute the render intent for a tool/call or tool/result event through the * presenters registered at this moment; every other event type gets none. A * result's presenter needs its call's parsed args — `argsFor` supplies them - * (live: the per-session call table; history: the full-log pairing index), - * returning undefined when the pairing is unavailable, which soft-falls to no - * view. Presenter or JSON.parse throws also soft-fall: + * (live: the per-session call table; history: an in-page backscan), returning + * undefined when the pairing is unavailable (e.g. the call fell off the page), + * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: * the client's documented default (generic JSON card) covers every miss. */ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined { @@ -442,8 +442,10 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => } /** - * Resolve a tool/result's call pairing by scanning a live session backwards - * for the matching tool/call after the open-call table missed. + * Resolve a tool/result's call pairing by scanning a window of events backwards + * for the matching tool/call. Used by the history path (the page is the + * window — a cross-page pairing soft-falls to no view) and by live-path table + * misses after a reconnect-eviction. */ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined { for (let i = events.length - 1; i >= 0; i--) { @@ -461,34 +463,6 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: return undefined } -/** Index durable call metadata once so every history result keeps its pair across page cuts. */ -function historyCallIndex(events: readonly SessionEvent[]): ReadonlyMap<string, HistoryToolCall> { - const calls = new Map<string, HistoryToolCall>() - for (const event of events) { - if (event.type !== 'tool/call') continue - calls.set(String(event.data.callId), { - name: event.data.name, - arguments: event.data.arguments, - time: event.time, - }) - } - return calls -} - -/** Parse one indexed history pair for a result presenter, soft-falling malformed arguments. */ -function historyArgs( - calls: ReadonlyMap<string, HistoryToolCall>, - callId: string, -): { name: string; args: unknown } | undefined { - const call = calls.get(callId) - if (call === undefined) return undefined - try { - return { name: call.name, args: JSON.parse(call.arguments) } - } catch { - return undefined - } -} - /** Render one detached history page through the same presenter path as ordinary history. */ function historyPage( ctx: Context, @@ -497,18 +471,10 @@ function historyPage( maxMessages: number | undefined, ): { events: HistoryEntry[]; hasMore: boolean } { const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) - const calls = historyCallIndex(events) return { events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => historyArgs(calls, callId)) - const call = event.type === 'tool/result' - ? calls.get(String(event.data.message.source.callId)) - : undefined - return { - event, - ...view === undefined ? {} : { view }, - ...call === undefined ? {} : { call }, - } + const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) + return { event, ...view === undefined ? {} : { view } } }), hasMore: page.hasMore, } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 697e5bdeae..4f10d92853 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -36,7 +36,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { - HistoryEntry, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index f47289e77b..9f9c4329e6 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,7 +11,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { - HistoryEntry, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' @@ -193,18 +193,10 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [ z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }), ]) as unknown as z.ZodType<ToolEventView> -/** Paired tool/call metadata carried with a paged tool/result. */ -export const historyToolCallSchema: z.ZodType<Wire<HistoryToolCall>> = z.object({ - name: z.string(), - arguments: z.string(), - time: z.number(), -}) - -/** One session.history item: raw event plus optional host-computed tool annotations. */ +/** One session.history item: the session event plus its optional host-computed tool view. */ export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({ event: sessionEventSchema, view: toolEventViewSchema.optional(), - call: historyToolCallSchema.optional(), }) as unknown as z.ZodType<Wire<HistoryEntry>> /** diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2a6da96db9..18315eef19 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -26,26 +26,14 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** Paired tool/call metadata carried beside a paged result whose call may be outside the page. */ -export interface HistoryToolCall { - /** Registered tool name used for keyed presentation dispatch. */ - name: string - /** Exact durable arguments JSON from the paired tool/call. */ - arguments: string - /** Unix epoch ms of the paired tool/call event. */ - time: number -} - /** - * One history page entry: the raw event plus optional host-computed render - * intent and result pairing. Both annotations are pagination-time derivations, - * never persisted; `call` preserves a tool/result's identity when its call - * event lies outside this page. + * One history page entry: the raw event plus the optional host-computed render + * intent (same semantics as the mux frame's `view` slot — a pagination-time + * derivation, never persisted). */ export interface HistoryEntry { event: SessionEvent view?: ToolEventView - call?: HistoryToolCall } /** diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 3b19a26b5e..43083545db 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -231,50 +231,9 @@ describe('mux live view computation', () => { ])) expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } }) expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } }) - expect(byKey.get('tool/result:h-term')?.call).toEqual({ - name: 'term', arguments: '{"cmd":"ls"}', time: byKey.get('tool/call:h-term')?.event.time, - }) expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false) - expect('call' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false) expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false) - expect(byKey.get('tool/result:h-bad')?.call?.arguments).toBe('{broken') expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false) - expect(byKey.get('tool/result:h-plain')?.call?.name).toBe('plain') - }) - - it('carries a result pair and computes its view when the call is outside the history page', async () => { - const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) - const session = ctx.sessions.create() - ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - session.append('turn/start', { turn: 1 }) - const call = session.append('tool/call', { - turn: 1, step: 1, callId: CallId('cross-page'), name: 'term', arguments: '{"cmd":"tail"}', - }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2 }) - appendUserText(session, 'newer message cuts the page') - const result = session.append('tool/result', { - turn: 1, step: 1, - message: createToolResultMessage({ - callId: CallId('cross-page'), - content: [{ type: 'text', text: 'late result' }], - isError: false, - }), - }, { surfaceOp: 'append' }) - - const response = await api.sessions.history({ - rpcId: RpcId('t-hist-cross-page'), - payload: { sessionId: session.id, maxMessages: 1 }, - }) - if (!response.result.ok) throw new Error('unreachable') - const entries = response.result.value.events - expect(entries.some(entry => entry.event.seq === call.seq)).toBe(false) - const entry = entries.find(candidate => candidate.event.seq === result.seq) - expect(entry).toMatchObject({ - call: { name: 'term', arguments: '{"cmd":"tail"}', time: call.time }, - view: { for: 'result', view: { card: 'terminal', output: 'done' } }, - }) }) it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 3a76dd9b07..b65861c1ae 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -190,16 +190,10 @@ describe('sessions domain schemas', () => { expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3) expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow() expect(sessionHistoryValueSchema.parse({ - events: [{ - event: { type: 'tool/result', seq: 3, time: 30, data: {} }, - call: { name: 'skill', arguments: '{"name":"review"}', time: 20 }, - }], + events: [], hasMore: false, modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })).toMatchObject({ - events: [{ call: { name: 'skill', arguments: '{"name":"review"}', time: 20 } }], - hasMore: false, - }) + }).hasMore).toBe(false) expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, From 0823a3484a6279171e9552696ea39be55ec250c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 14:03:11 +0800 Subject: [PATCH 187/190] fix(web): report a wrapped paste as the same API key format failure --- .../2026-08-06-api-key-format-validation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-06-api-key-format-validation.md | 2 +- .../2026-08-06-api-key-format-validation.zh.md | 2 +- packages/client/ui-models/README.i18n.yaml | 4 ++-- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- packages/client/ui-models/src/client/apiKey.ts | 12 +++++++++--- packages/client/ui-models/src/client/locales.ts | 2 -- packages/client/ui-models/tests/components.spec.tsx | 4 ++-- .../client/ui-models/tests/provider-form.spec.tsx | 2 +- 10 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index ae2d1d5934..d5418088d7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: 4666f6197dbed060d00c77fdd6b87842141c10f4 -2026-08-06-api-key-format-validation.zh.md: 75c98bd29cf009e69ceb450432f540e3f49d99d0 +2026-08-06-api-key-format-validation.md: c85d03119565a25abb37a0d32550d46796148134 +2026-08-06-api-key-format-validation.zh.md: 84e5458675995d2454fa4becc5e6466f511c4932 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index 4666f6197d..c85d031195 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -22,7 +22,7 @@ One rule defines a legal key: **after trimming, non-empty, and every character w This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. -A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. +A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do. ### Invariants belong at every layer; heuristics belong where the human is diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 75c98bd29c..84e5458675 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -22,7 +22,7 @@ Status: implemented 这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 -第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 +第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。 ### 不变量属于每一层,启发式属于人所在的那一层 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 3db62f6c31..ca475596fd 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: e3328bb5fd2cf812b05dc26bf534226818132631 -README.zh.md: 20e40cc571a9123b50dfb28565c5562937e03189 +README.md: a9799916a91cb7416765c387b59c9172fa477666 +README.zh.md: e73100fa7d23a4025dffe471615401617a4af811 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index e3328bb5fd..a9799916a9 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes fails too; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 20e40cc571..e73100fa7d 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值也会失败——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts index 5fd1d22ee6..5e3aa692c8 100644 --- a/packages/client/ui-models/src/client/apiKey.ts +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -22,8 +22,14 @@ const LEGAL_API_KEY = /^[\x21-\x7E]+$/ */ const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/ -/** Copy key naming why a typed key cannot be saved. */ -export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' +/** + * Copy key naming why a typed key cannot be saved. A wrapped paste reports the + * same format failure as an illegal character: the reader's next move is the + * same either way — look at the key and paste it again — so naming the two + * causes apart would spend the field's one line on a distinction that changes + * nothing about what to do. + */ +export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' /** Whether a value is wrapped in one matching pair of quotes. */ function isQuoted(value: string): boolean { @@ -46,7 +52,7 @@ export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined { if (draft.length === 0) return undefined const value = draft.trim() if (value.length === 0) return 'keyBlank' - if (ENV_LINE.test(value) || isQuoted(value)) return 'keyLooksWrapped' + if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters' if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters' return undefined } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 85f7c14f97..336bf498f5 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -49,7 +49,6 @@ export const en = { keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.', keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', - keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', modelNameInvalid: 'Display name cannot be empty.', @@ -137,7 +136,6 @@ export const zh: typeof en = { keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。', keyIllegalCharacters: '该 API 密钥格式错误,请检查。', - keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 7228d472cd..5bb2da387c 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1121,8 +1121,8 @@ describe('apiKeyFailure', () => { ['double quotes', '"sk-abc"'], ['single quotes', '\'sk-abc\''], ['backticks', '`sk-abc`'], - ])('fails %s as wrapped', (_label, draft) => { - expect(apiKeyFailure(draft)).toBe('keyLooksWrapped') + ])('fails %s as a format failure', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters') }) it('needs a matching closing quote before it calls a value wrapped', () => { diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 5d505386e6..11271561b8 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -987,7 +987,7 @@ describe('API key field', () => { fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } }) - expect(screen.getByText(en.keyLooksWrapped)).toBeTruthy() + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() expect(buttonNamed(en.apply).disabled).toBe(true) }) From 8e57dd1dac85be4430ff6a214f8951d875dfbbcd Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 7 Aug 2026 16:15:10 +0800 Subject: [PATCH 188/190] fix(web): render Skill icon at 14px --- .../feature/2026-08-06-web-skill-tool-row.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-06-web-skill-tool-row.md | 2 +- .../implemented/feature/2026-08-06-web-skill-tool-row.zh.md | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 ++-- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/SkillRow.tsx | 2 +- packages/client/ui-skill/tests/skill-row.spec.tsx | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml index a9ee64e640..3be2c476e5 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-web-skill-tool-row.md -2026-08-06-web-skill-tool-row.md: 6583062f38b0e9cff059fa4477313ff6a5bdd2aa -2026-08-06-web-skill-tool-row.zh.md: 3d5c4b712896c2cf41df3ec913c597f7f791486c +2026-08-06-web-skill-tool-row.md: fcf5c3b5b61c94b0823fe54624c3dc906c520348 +2026-08-06-web-skill-tool-row.zh.md: bef36df44d97af3993c9760a6b6d3add7b7c932c diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md index 6583062f38..fcf5c3b5b6 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -12,7 +12,7 @@ The Web transcript renders `skill` calls through the generic fallback row, so a `ui-skill` registers a component under the existing `conversation.chat.toolview` keyed slot with key `skill`. The component owns its row chrome from the public `ToolRowProps` contract, matching the independent registrant posture used by the Bash sample instead of importing conversation-private components. -The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. +The collapsed row uses a 14-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. The row derives every visible value from a paired call/result slice in the current runtime window. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. If pagination leaves the call outside the window, the result has no tool identity and remains on the generic fallback rather than extending the history wire contract. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md index 3d5c4b7128..bef36df44d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -12,7 +12,7 @@ Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使 `ui-skill` 在现有的 `conversation.chat.toolview` 键控 slot 下注册 key 为 `skill` 的组件。该组件基于公开的 `ToolRowProps` 契约自行实现行 chrome,沿用 Bash 示例的独立注册方姿态,而不导入 conversation 私有组件。 -收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 +收起的行使用 14 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 该行的所有可见值均派生自当前 runtime 窗口中已配对的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。如果分页将调用留在窗口外,结果便没有工具身份,并继续使用通用后备路径,而不是扩展 history 协议契约。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 57a1ff1676..ca4bc68ebf 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-skill/README.md -README.md: a9506fe563b94fb4d1f9afd882216e023b0c2d13 -README.zh.md: 6af5d3eb8820dacc2ab569be8b830481dd45fb9a +README.md: f70bd2780f255cd8e0c64acb3da3863e10c4fa9d +README.zh.md: 6eb6cbd3ae196a540e161a3a23f9df2136824f2e diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index a9506fe563..f70bd2780f 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -10,7 +10,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou ## Skill tool row -The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change. +The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 14-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change. ## Model Experience diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 6af5d3eb88..6eb6cbd3ae 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -10,7 +10,7 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## skill 工具行 -浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 +浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 14 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 ## 模型体验 diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index 076da55d52..65b474825a 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -82,7 +82,7 @@ function leadingFor(state: SkillRowState): ReactNode { switch (state) { case 'error': return <StateDot state="error" /> case 'stopped': return <StateDot state="warning" /> - default: return <IconSkillOutline16 /> + default: return <IconSkillOutline16 size={14} /> } } diff --git a/packages/client/ui-skill/tests/skill-row.spec.tsx b/packages/client/ui-skill/tests/skill-row.spec.tsx index 4143b4a7a2..05b84ceda5 100644 --- a/packages/client/ui-skill/tests/skill-row.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.spec.tsx @@ -56,7 +56,7 @@ describe('SkillRow', () => { const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' }) expect(row.getAttribute('aria-expanded')).toBe('false') expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok') - expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('16') + expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('14') expect(screen.queryByLabelText('说明')).toBeNull() fireEvent.click(row) From 2efce69d7921a0b2e1610f15d6efb5344b3b5b83 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:15:27 +0800 Subject: [PATCH 189/190] fix(client): route turn-tail through chain selector --- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/chat/AssistantMarkdown.tsx | 14 ++++----- .../src/client/chat/ChatView.tsx | 6 ++-- .../src/client/contract/slots.ts | 11 ++++--- .../ui-conversation/tests/chat-view.spec.tsx | 4 ++- .../src/client/ProducedFiles.tsx | 29 +++++++------------ .../ui-deliverables/src/client/index.ts | 4 +-- .../src/client/turn-deliverables.ts | 11 +++++++ .../tests/produced-files.spec.tsx | 15 ++++------ 9 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8eb78139c4..24325c714e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -303,7 +303,7 @@ export function apply(ctx: Context): void { children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, - 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 687e1ae86c..bc1c6c7e32 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,12 +9,13 @@ // their branch action is enabled only when the node is also the completed // turn's transcript tail. Think / tool-head-only nodes stay chrome-free. -import { memo, useMemo, type ReactNode } from 'react' +import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts' import { hasContentText } from './chat-flow.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' @@ -40,9 +41,8 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined - /** Turn-tail content (the chat view's turnTail hole, rendered by the - * owner); omitted for a mid-turn assistant. */ - tail?: ReactNode | undefined + /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ + turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -86,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, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, tail, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -124,7 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && <span className={css.stopped}>{t('message.stopped')}</span>} </div> - {showActions && tail} + {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)} {showActions && ( <MessageIconActions text={copyText(blocks)} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index ee43ff080d..b0907f5a80 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -335,7 +335,7 @@ function StreamingTail({ useSession, t }: { * render through the declared keyed hole's renderSlot share). */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, + useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) const turnTimings = useSession(s => s.turnTimings) @@ -600,8 +600,8 @@ export function ChatView({ seq={node.seq} onFork={forkAt} forkUnavailable={!branchSeqs.has(node.seq)} - tail={actionSeqs.has(node.seq) - ? renderSlot('conversation.chat.turnTail', { nodes, seq: node.seq, openFile }) + turnTail={actionSeqs.has(node.seq) + ? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } } : undefined} t={t} /> diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1246433a33..89f7986dba 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -47,14 +47,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } /** - * The chat view's turn-tail hole: rendered between a closing assistant + * The chat view's turn-tail chain: rendered between a closing assistant * message's body and its IconActions footer, once per turn (the render - * site elects the closing seq). Declared by the chat view entry; feature - * plugins (ui-deliverables' produced-files row) derive what they show - * from the owner currency, and an unregistered hole renders nothing — - * composing such a plugin out of cordis.yml turns its surface off. + * site elects the closing seq). Entries derive a match from the owner + * currency before mounting, so presentation components never mount only + * to return null; an all-declined chain renders nothing. */ - 'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps } + 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } /** * The composer takeover chain: entries are selector-routed replacements * of the default InputBar. Declared by this package's 'conversation' diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f2bb8709d7..b8cd94de52 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -130,6 +130,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) { const chat = createChatStore().create() const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot'] + const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => + opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain'] // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (render-prop pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</> @@ -144,6 +146,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) { useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, + renderSlotChain, SessionProvider: SessionProviderStub, openDetails, openFile, @@ -733,7 +736,6 @@ describe('ChatView', () => { // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 h.props.renderSlot = ((key: string, _owner: object) => { - // The turnTail hole renders through the same share; only tool rows count here. if (key !== 'conversation.chat.toolview') return null rowRenders += 1 return <div data-testid="counting-row" /> diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 609a688586..ab85869de2 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,14 +1,11 @@ // ProducedFiles: the produced-file row a finished turn ends with. The paths -// come from the mutation tools' follow-along locations (see -// producedForClosing), never from the closing prose, so the answer carries -// its own output whether or not the model remembered to name it. Clicking one -// goes through the same openFile the tool rows use — the Host's own opener, -// on the Host machine. +// come pre-matched by the turn-tail chain from the mutation tools' +// follow-along locations, never from the closing prose. Clicking one goes +// through the same openFile the tool rows use — the Host's own opener, on the +// Host machine. -import { useMemo } from 'react' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { producedForClosing } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' @@ -21,21 +18,17 @@ function basename(path: string): string { return at === -1 ? path : path.slice(at + 1) } -/** Full props: the turn-tail owner currency plus this plugin's locale seat. */ -export type ProducedFilesProps = TurnTailOwnerProps & PropsLocale<typeof NS> +/** Matched paths plus the opener and locale seats needed to present them. */ +export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & { + matched: readonly string[] +} & PropsLocale<typeof NS> /** * Render one turn's produced files as openable chips. - * @param props - the tail hole's owner currency (snapshot nodes, the closing - * assistant's seq, the chat view's file opener) and the locale seat. - * @returns The row, or `null` when the turn produced nothing. + * @param props - selector-matched paths, the chat view's file opener, and the locale seat. + * @returns The produced-files row. */ -export function ProducedFiles({ nodes, seq, openFile, t }: ProducedFilesProps) { - // Per-closing-message derivation over the windowed snapshot: O(nodes) on - // node-identity change only, which is the same cadence the owning view - // re-derives its own flow at. - const paths = useMemo(() => producedForClosing(nodes, seq), [nodes, seq]) - if (paths.length === 0) return null +export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) { const shown = paths.slice(0, SHOWN) const hidden = paths.length - shown.length return ( diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 536c019b01..6dc7bc4b84 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' +import { selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -34,8 +35,7 @@ export function apply(ctx: ClientContext): void { 'conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', - id: 'produced-files', - order: 0, + select: selectProducedFiles, locale: NS, }, ProducedFiles), ) diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index faa0455b37..a3ddf40b59 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -4,6 +4,7 @@ * own follow-along `locations`, never the closing prose. */ import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** * Paths a call view reports having created or changed, by render intent rather @@ -76,3 +77,13 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } return [] } + +/** + * Claim the turn-tail chain only when its closing turn produced files. + * @param owner - Turn-tail owner currency for the closing assistant. + * @returns Produced paths as the component's match, or null to decline before mount. + */ +export function selectProducedFiles({ nodes, seq }: TurnTailOwnerProps): readonly string[] | null { + const paths = producedForClosing(nodes, seq) + return paths.length === 0 ? null : paths +} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index e5d92424a3..49e41ebd86 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -15,7 +15,7 @@ import type { import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { producedForClosing } from '../src/client/turn-deliverables.ts' +import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -64,6 +64,8 @@ describe('producedForClosing derivation', () => { assistant(9, 'second turn', 2), ] expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull() // A turn that produced nothing yields the empty list, and so does an // anchor the window does not contain. expect(producedForClosing(nodes, 9)).toEqual([]) @@ -126,8 +128,7 @@ describe('ProducedFiles row', () => { // it shows and says so rather than dropping the rest silently. const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] const openFile = vi.fn<(path: string) => void>() - const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)] - const view = render(<ProducedFiles nodes={nodes} seq={3} openFile={openFile} t={t} />) + const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />) expect(view.getByText('产物')).toBeTruthy() // Chips carry the basename; the full path stays reachable as the title. const chip = view.getByRole('button', { name: '打开 deep/a.html' }) @@ -138,12 +139,6 @@ describe('ProducedFiles row', () => { fireEvent.click(chip) expect(openFile).toHaveBeenCalledWith('deep/a.html') }) - - it('a turn that produced nothing renders no row at all', () => { - const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)] - const view = render(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} t={t} />) - expect(view.container.firstChild).toBeNull() - }) }) describe('package shells', () => { @@ -169,7 +164,7 @@ describe('plugin registration', () => { // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', - children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } }, + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() From daac50c84ce3637bffbdeac350cac0a6ef0beb76 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:19:26 +0800 Subject: [PATCH 190/190] fix(client): name turn-tail selector owner --- .../client/ui-deliverables/src/client/turn-deliverables.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index a3ddf40b59..c9754d1da4 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -83,7 +83,8 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb * @param owner - Turn-tail owner currency for the closing assistant. * @returns Produced paths as the component's match, or null to decline before mount. */ -export function selectProducedFiles({ nodes, seq }: TurnTailOwnerProps): readonly string[] | null { +export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null { + const { nodes, seq } = owner const paths = producedForClosing(nodes, seq) return paths.length === 0 ? null : paths }