From 0ccd3ed4638f5ae10771cc74147fcfb8a92a7e2d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 13:34:45 +0800 Subject: [PATCH 001/130] feat(feedback): add a /feedback command recorded through the command plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a global `/feedback` command so a user can record a remark about the session without spending a model turn. `/feedback ` acknowledges; empty or whitespace-only input returns a usage error. The plugin appends no session event of its own. `dsh-commands` already writes a `command/run` / `command/done` pair for every dispatched command, carrying the verbatim text and the settled outcome, and both records are log-only and non-surface. The feedback is therefore durably in the session log and invisible to the model without this package touching the log format. Text is never parsed, so `/feedback /plan felt slow` records that literal content. Nothing consumes the records; capture is deliberately inert. New group `packages/feedback/` — no existing group owns feedback capture. Its row raises the packages/README.md word ceiling by 10, which had no headroom; one redundant sentence there was removed to offset most of the cost. --- .../2026-07-28-feedback-command.i18n.yaml | 6 + .../feature/2026-07-28-feedback-command.md | 61 ++++++ .../feature/2026-07-28-feedback-command.zh.md | 61 ++++++ docs/config-catalog.md | 3 +- docs/module-graph.md | 9 +- packages/README.i18n.yaml | 4 +- packages/README.md | 3 +- packages/README.zh.md | 3 +- packages/examples/tui-demo/package.json | 2 + packages/examples/tui-demo/src/index.ts | 4 +- .../examples/tui-demo/tests/tui-agent.spec.ts | 27 +-- packages/examples/tui-demo/tsconfig.json | 3 + packages/feedback/README.i18n.yaml | 6 + packages/feedback/README.md | 11 ++ packages/feedback/README.zh.md | 11 ++ .../command-feedback/README.i18n.yaml | 6 + packages/feedback/command-feedback/README.md | 60 ++++++ .../feedback/command-feedback/README.zh.md | 60 ++++++ .../feedback/command-feedback/package.json | 44 +++++ .../feedback/command-feedback/src/index.ts | 40 ++++ .../command-feedback/src/invariant.ts | 30 +++ .../tests/command-feedback.spec.ts | 176 ++++++++++++++++++ .../tests/loader-composition.spec.ts | 105 +++++++++++ .../feedback/command-feedback/tsconfig.json | 24 +++ pnpm-lock.yaml | 30 +++ scripts/doc-budgets.manifest.json | 2 +- tsconfig.base.json | 2 + tsconfig.host.json | 1 + 28 files changed, 774 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-feedback-command.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md create mode 100644 packages/feedback/README.i18n.yaml create mode 100644 packages/feedback/README.md create mode 100644 packages/feedback/README.zh.md create mode 100644 packages/feedback/command-feedback/README.i18n.yaml create mode 100644 packages/feedback/command-feedback/README.md create mode 100644 packages/feedback/command-feedback/README.zh.md create mode 100644 packages/feedback/command-feedback/package.json create mode 100644 packages/feedback/command-feedback/src/index.ts create mode 100644 packages/feedback/command-feedback/src/invariant.ts create mode 100644 packages/feedback/command-feedback/tests/command-feedback.spec.ts create mode 100644 packages/feedback/command-feedback/tests/loader-composition.spec.ts create mode 100644 packages/feedback/command-feedback/tsconfig.json 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 new file mode 100644 index 0000000000..ba56da8945 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.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-07-28-feedback-command.md +2026-07-28-feedback-command.md: ae32d3908d568c4a511e8d9e2b8cf50569fb80bf +2026-07-28-feedback-command.zh.md: f69dbf6a50161e7f5048b76be46bc4063f9e757a diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md new file mode 100644 index 0000000000..ae32d3908d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -0,0 +1,61 @@ +# Agent Note: `/feedback` command + +Status: implemented + +English | [中文](2026-07-28-feedback-command.zh.md) + +## Problem + +A user who notices something wrong mid-session has nowhere to put that observation. Telling the model wastes a turn, changes the conversation the user was having, and buries the remark in derived history where no later reader can find it. Writing it outside the session loses the context that makes it meaningful — which session, at which point, against which work. + +The capture surface has to be usable at the moment of annoyance, which rules out anything requiring the user to leave the TUI, and it must not perturb the run in progress: no model tokens, no turn of work, no change to the request the user is waiting on. + +## Decision + +`@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback ` acknowledges; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. + +The plugin appends **no session event of its own**. `dsh-commands` already writes a `command/run` / `command/done` pair for every dispatched command, carrying the command name, the verbatim unparsed suffix, the invocation source, and the settled outcome. Those records are log-only and non-surface, so the feedback lands in the session log and stays invisible to the model without this package contributing anything to the log format. The appends start persistence's ordinary eager drain; nothing forces a flush, so the acknowledgement reports that the entry is recorded in the log rather than already on disk. + +Capture is deliberately inert: nothing in this repository reads those records back. + +### Why no dedicated `session/feedback` event + +An earlier iteration declared one. It was removed because it duplicated a record the registry already writes: both would carry the same text, appended microseconds apart, and a consumer would have to decide which is authoritative. Selecting `command/run` records by command name is enough to find feedback, and it keeps this package free of the session event format entirely — no `SessionEventMap` merge, no invariant relation, no persistence catalog entry. + +The cost is that the recorded text is the raw suffix including its leading separator whitespace, and that feedback is distinguished from other commands only by name. Both are read-time concerns for a consumer that does not yet exist; neither justifies a second durable record now. + +### Why the model never sees it + +Feedback is about the session, not input to it. Injecting it as a user message would change the next model request, contradicting the requirement that recording not perturb the run, and would make the remark part of the conversation it comments on. `command/run` and `command/done` are absent from `SurfaceEventType`, so they cannot acquire a `surfaceOp` or enter derived history even by mistake. + +### Verbatim text + +Nothing is parsed. `/feedback /plan felt slow` records that literal text; the leading `/plan` is content, not a nested command. The handler trims only to decide whether any text was supplied. Control-word grammar of the kind `/goal` uses would make the corresponding literal feedback impossible to express, which is the opposite of what a capture surface is for. + +### 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. + +## Alternatives considered + +**Declare a dedicated `session/feedback` log-only event.** Implemented first, then removed. It gave feedback a first-class queryable type with pre-trimmed text, but duplicated the registry's record, added a `SessionEventMap` member and persistence-catalog entry to the frozen log format, and created two records of one act with no rule for which wins. + +**Inject feedback as a user message via `agent.inject()`.** Needs no new event type and reuses the path `/goal` mutations take. Rejected: it makes the feedback model-visible, so it enters the next request, changes the run being commented on, and consumes tokens — contradicting all three parts of the no-perturbation requirement. + +**Make `/feedback` a true no-op that records nothing.** The most literal reading of "does not do anything". Rejected because it makes the command pointless: the stated requirement was that the remark reach the session log. + +**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. + +**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. + +## Consequences + +The TUI mounts the command unconditionally — no configuration, no dependency on the goal stack. The headless CLI, ACP, and JSON-RPC apps do not consume `ctx.commands`, so `/feedback` is unavailable there. + +This package is now small enough that its whole contract is the command definition plus one validation branch. It owns no session event, so it needs no invariant relation and cannot affect replay, forking, or crash recovery. + +Deferred: no consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; the recorded text is untrimmed, so a consumer trims at read time; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. + +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 new file mode 100644 index 0000000000..f69dbf6a50 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -0,0 +1,61 @@ +# Agent Note: `/feedback` 命令 + +Status: implemented + +[English](2026-07-28-feedback-command.md) | 中文 + +## 问题 + +用户在会话中途发现问题时,没有地方记下这个观察。告诉模型会浪费一个轮次、改变用户原本进行的对话,并把这条评论埋进派生历史,使后续读者无法找到它。写到会话之外则会丢失让它有意义的上下文:属于哪个会话、处于哪个时点、针对哪项工作。 + +采集接口必须能在用户产生不满的那一刻使用,因此任何需要用户离开 TUI 的方案都不可行;它还不能扰动正在进行的运行:不消耗模型 token、不产生工作轮次、不改变用户正在等待的请求。 + +## 决策 + +位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback ` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。 + +该插件**不追加属于自己的会话事件**。`dsh-commands` 已经为每个已分发命令写入一对 `command/run` / `command/done`,携带命令名、原样未解析的后缀、调用来源以及结算结果。这些记录仅写入日志且非 surface,因此反馈会进入会话日志并对模型保持不可见,而本包无需向日志格式贡献任何内容。这些追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是条目已记录在日志中,而非已经落盘。 + +采集刻意不产生后续动作:本仓库中没有任何代码读回这些记录。 + +### 为何不设专用的 `session/feedback` 事件 + +早先的实现声明过该事件,后来将其移除,因为它重复了注册表已经写入的记录:两者会携带相同文本、相隔极短时间先后追加,而消费方还得判断以哪一条为准。依据命令名筛选 `command/run` 记录已足以找到反馈,同时让本包完全不涉及会话事件格式——没有 `SessionEventMap` 合并、没有不变式关系、没有持久化目录条目。 + +代价是被记录的文本为原始后缀,包含其前导分隔空白;且反馈仅凭命令名与其他命令相区分。两者都属于尚不存在的消费方在读取时需要处理的问题,目前都不足以支撑再增加一条持久记录。 + +### 为何模型永不看到它 + +反馈是关于会话的,而不是会话的输入。将其作为 user 消息注入会改变下一次模型请求,与「记录不得扰动运行」的要求相冲突,也会让该评论成为它所评论的那段对话的一部分。`command/run` 与 `command/done` 不属于 `SurfaceEventType`,因此即便出错也无法获得 `surfaceOp` 或进入派生历史。 + +### 原样文本 + +不做任何解析。`/feedback /plan felt slow` 记录的就是该字面文本;开头的 `/plan` 是内容,而非嵌套命令。处理器仅为判断是否提供了文本而修剪。若采用 `/goal` 那样的控制词语法,对应的字面反馈将无法表达,这与采集接口的目的正好相反。 + +### 一个新的分组 + +`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组目前只有一个包;未来的消费方应加入该分组,而不是迫使这个包不断膨胀。 + +## 考虑过的替代方案 + +**声明专用的 `session/feedback` 仅日志事件。** 先实现后移除。它让反馈拥有一等的可查询类型和预先修剪的文本,但重复了注册表的记录,向已冻结的日志格式新增了一个 `SessionEventMap` 成员与持久化目录条目,并使同一行为产生两条记录而没有取舍规则。 + +**通过 `agent.inject()` 将反馈作为 user 消息注入。** 无需新增事件类型,并复用 `/goal` 变更所走的路径。已否决:它会让反馈对模型可见,从而进入下一次请求、改变正被评论的那次运行并消耗 token——与「不得扰动」要求的三个方面全部冲突。 + +**让 `/feedback` 成为真正的空操作,什么都不记录。** 这是对「什么都不做」最字面的理解。已否决:这会使命令失去意义——明确的要求是让这条评论进入会话日志。 + +**在现有包中注册该命令**,例如 `packages/ui/commands`。可省去新分组及其双语 README。已否决:`ctx.commands` 是注册表,而不是任意命令实现的归属地;且请求者明确要求独立的包。 + +**从文本中解析结构**(类别前缀、严重程度标记)。已否决,属于投机设计:目前没有消费方使用该结构,而任何控制词语法都会让对应的字面反馈无法记录。原样文本是未来消费方可以收窄的最宽接口;而已被解析的接口无法事后放宽。 + +**改为提供面向模型的工具。** 已否决:反馈是人类的直接观察。经由模型会消耗一个轮次、让模型改写用户的原话,并使记录取决于模型是否选择调用该工具。 + +## 后果 + +TUI 无条件挂载该命令:没有配置,也不依赖 goal 栈。无头 CLI、ACP 和 JSON-RPC 应用不消费 `ctx.commands`,因此 `/feedback` 在那里不可用。 + +本包现已小到其全部契约就是命令定义加一个校验分支。它不拥有任何会话事件,因此无需不变式关系,也不可能影响回放、fork 或崩溃恢复。 + +延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;被记录的文本未修剪,需由消费方在读取时处理;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 + +本次变更不附带 snapshot。AGENTS.md 要求面向产品用户的可见行为变更通过可运行示例附带无密钥 snapshot;此项按请求者的明确指示跳过。包测试连同一个基于真实 `cordis.yml` 的 Loader 组合测试即为全部证据,此外还有在组装后 TUI 中的交互验证。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 68343a6a19..2c2d68e934 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1994,7 +1994,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:40`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -2219,6 +2219,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) +- `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 703f4c8abf..cc680d7c96 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -181,6 +181,9 @@ flowchart TD pkg_jsonrpc_demo["jsonrpc-demo"] pkg_tui_demo["tui-demo"] end + subgraph group_feedback["packages/feedback"] + pkg_command_feedback["command-feedback"] + end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end @@ -604,6 +607,8 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_command_feedback --> pkg_commands + pkg_command_feedback --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -928,6 +933,7 @@ flowchart TD pkg_tui_demo --> pkg_agent pkg_tui_demo --> pkg_agent_loop pkg_tui_demo --> pkg_agent_spine_demo + pkg_tui_demo --> pkg_command_feedback pkg_tui_demo --> pkg_command_goal pkg_tui_demo --> pkg_commands pkg_tui_demo --> pkg_invariants @@ -1069,6 +1075,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`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) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants) | | [`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) | @@ -1116,6 +1123,6 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-feedback`](../packages/feedback/command-feedback), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index ba5ab61b06..0510fee0b7 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d -README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc +README.md: b283af83596b738deeb6fc482fb4ff18bedf8df8 +README.zh.md: 91bc90ff05b849aaeec1ce1010a0e5a45b5a402a diff --git a/packages/README.md b/packages/README.md index 7a86e0f034..b283af8359 100644 --- a/packages/README.md +++ b/packages/README.md @@ -12,6 +12,7 @@ Packages live at `packages///`; groups are containers, while names r |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | +| [`feedback/`](feedback/README.md) | Recorded human feedback | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | @@ -50,7 +51,7 @@ Packages live at `packages///`; groups are containers, while names r | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | -Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table. +New packages join an existing group; a new group updates its README and this table. ## Dependencies diff --git a/packages/README.zh.md b/packages/README.zh.md index bfcba626be..91bc90ff05 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -12,6 +12,7 @@ |---|---|---| | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | +| [`feedback/`](feedback/README.md) | 记录人类对会话的反馈 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | | [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | @@ -50,7 +51,7 @@ | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | -组用于区分产品 API 与支持基础设施。新包加入现有组;新组则更新其 README 和此表。 +新包加入现有组;新组则更新其 README 和此表。 ## 依赖 diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 50145e6c29..9c48f98511 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-command-goal": "^0.0.1", + "@deepseek-ai/dsh-command-feedback": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index c60ba94b3c..9d2f6a3bdf 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -1,6 +1,6 @@ /** * Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) - * plus persisted goals, human commands, JSONL persistence, keyboard-backed + * plus persisted goals, human commands including `/feedback`, JSONL persistence, keyboard-backed * user interaction, and one pre-created agent whose exact session identity the * TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin * intentionally exposes named exports only; a default export would hide its @@ -16,6 +16,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import CommandService from '@deepseek-ai/dsh-commands' import * as commandGoal from '@deepseek-ai/dsh-command-goal' +import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SessionPersistenceJsonl, { @@ -122,6 +123,7 @@ export function composeTuiApp(ctx: Context, config: Config): void { const goals = config.goals ?? {} const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT ctx.plugin(CommandService) + ctx.plugin(commandFeedback) if (goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot, diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index f647b3d9c6..aa483e7b1b 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -49,6 +49,7 @@ describe('dsh-tui-demo app', () => { expect(calls.map(call => call.name)).toEqual([ 'CommandService', + 'command-feedback', 'command-goal', 'SessionPersistenceJsonl', 'session-checkpoint-policy', @@ -61,14 +62,14 @@ describe('dsh-tui-demo app', () => { 'tool-ask-user', ]) expect(calls[0]?.config).toBeUndefined() - expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) - expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') }) - expect(calls[5]?.config).toEqual({ + expect(calls[3]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) + expect(calls[5]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') }) + expect(calls[6]?.config).toEqual({ maxReferences: 2, candidateLimit: 7, maxReferenceBytes: 1234, }) - const tuiConfig = calls[8]?.config as { sessionId: string } + const tuiConfig = calls[9]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', @@ -76,7 +77,7 @@ describe('dsh-tui-demo app', () => { maxToolOutputLines: 3, }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[9]?.config as { + const spineConfig = calls[10]?.config as { readonly agents: Array> readonly goals: Record readonly maxParallelToolCalls: number @@ -109,11 +110,11 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - expect(calls[2]?.config).toEqual({ root: './.sessions' }) - expect(calls[5]?.config).toEqual({}) + expect(calls[3]?.config).toEqual({ root: './.sessions' }) + expect(calls[6]?.config).toEqual({}) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[9]?.config as { agents: Array> }).agents[0]).toMatchObject({ + expect(calls[9]?.config).toEqual({ sessionId: 'persisted-session' }) + expect((calls[10]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', }) @@ -129,12 +130,14 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - const tuiConfig = calls[7]?.config as { sessionId: string } + const tuiConfig = calls[8]?.config as { sessionId: string } expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[8]?.config as { agents: Array> }).agents[0]) + expect((calls[9]?.config as { agents: Array> }).agents[0]) .toMatchObject({ sessionId: tuiConfig.sessionId }) expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[8]?.config).toMatchObject({ goals: false }) + // `/feedback` is unconditional: disabling goals must not remove it. + expect(calls.map(call => call.name)).toContain('command-feedback') + expect(calls[9]?.config).toMatchObject({ goals: false }) }) it('has the namespace-plugin export shape so the Loader keeps its schema', () => { diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index d26d5b7da6..bfd6d5554c 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../goal/command-goal" }, + { + "path": "../../feedback/command-feedback" + }, { "path": "../agent-spine-demo" }, diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml new file mode 100644 index 0000000000..eca3b1c420 --- /dev/null +++ b/packages/feedback/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/feedback/README.md +README.md: ab7bc6f3e3a3be0c280855ff80e92c7d7a7e665e +README.zh.md: 9c050ac42aa468895c04124a76a3bce58756df0e diff --git a/packages/feedback/README.md b/packages/feedback/README.md new file mode 100644 index 0000000000..ab7bc6f3e3 --- /dev/null +++ b/packages/feedback/README.md @@ -0,0 +1,11 @@ +# feedback/ — recorded human feedback + +English | [中文](README.zh.md) + +The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it. + +| Package | Role | ctx key | +|---|---|---| +| `command-feedback/` | Human-facing `/feedback` command recorded through the command plane | — | + +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 the command records from the session log rather than changing how they are captured. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md new file mode 100644 index 0000000000..9c050ac42a --- /dev/null +++ b/packages/feedback/README.zh.md @@ -0,0 +1,11 @@ +# feedback/:记录的人类反馈 + +[English](README.md) | 中文 + +feedback 家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `command-feedback/` | 面向用户的 `/feedback` 命令,通过命令平面完成记录 | 无 | + +被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取命令记录,而不是改变它们的采集方式。 diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml new file mode 100644 index 0000000000..37f10ac485 --- /dev/null +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md +README.md: 90992b7295536a9099766910f616e640d4b4bcfe +README.zh.md: a7c4f03997cea182ed24dcfc7f309dc3bd872d5e diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md new file mode 100644 index 0000000000..90992b7295 --- /dev/null +++ b/packages/feedback/command-feedback/README.md @@ -0,0 +1,60 @@ +# @deepseek-ai/dsh-command-feedback + +English | [中文](README.zh.md) + +Human-facing `/feedback` capture. The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. + +## Command contract + +| Input | Result | +|---|---| +| `/feedback ` | Acknowledge with `Feedback recorded.` The registry's `command/run` record carries the verbatim text. | +| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | + +Feedback text is never parsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own record; nothing is replaced or merged. + +## What this plugin does and does not do + +The command records a remark and does nothing else. It appends no session event of its own, starts no model work, and no plugin in this repository reads its records. + +The record is the command registry's own `command/run` / `command/done` pairing, which [`dsh-commands`](../../ui/commands/README.md) appends for every dispatched command. Those appends start persistence's ordinary eager drain; neither the registry nor this command forces a `session/flush`, so the acknowledgement means the entry is in the log, not that it has already reached disk. `command/run` carries the command name, the verbatim unparsed suffix, and the invocation source; the paired `command/done` carries the outcome. Both are log-only and are absent from the ordered surface, from `deriveMessages()`, and from every model request. A rejected empty input still leaves that pairing, settled as `kind: 'error'`, so no entry can be mistaken for accepted feedback. + +A dedicated `session/feedback` event was considered and rejected: it would duplicate a record the registry already writes, and a consumer can select feedback by the command name it already stores. + +## Composition + +The producer injects only `commands`. A custom app mounts the registry plus this plugin: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: command-feedback + name: '@deepseek-ai/dsh-command-feedback' +``` + +The TUI app mounts this command unconditionally; it has no configuration and no dependency on the persisted-goal stack. The headless CLI, ACP automation, and JSON-RPC adapters do not consume `ctx.commands`, so they do not expose it. + +## Model Experience + +### Human `/feedback` capture + +#### What the model sees + +Nothing. The slash input, the recorded text, and the acknowledgement are all absent from model requests. The registry's `command/run` and `command/done` records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests. + +#### Token effect + +Zero direct token effect. Neither an accepted entry nor a usage error adds model tokens, in the recording turn or any later one. + +#### KV Cache effect + +Independent of the model request path. Recording appends to the session log only, leaving an already-reusable request prefix untouched. Nothing this package contributes can invalidate cache reuse. + +## 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 it; a consumer is a separate package that selects `command/run` records by command name. +- **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. +- **Untrimmed text in the record** — the handler trims only to validate; `command/run` stores the raw suffix, including its leading separator whitespace, so a consumer trims at read time. +- **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)`. +- **TUI only in the shipped apps** — the headless CLI, ACP automation, and JSON-RPC adapters do not mount `ctx.commands`, so `/feedback` is unavailable there. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md new file mode 100644 index 0000000000..a7c4f03997 --- /dev/null +++ b/packages/feedback/command-feedback/README.zh.md @@ -0,0 +1,60 @@ +# @deepseek-ai/dsh-command-feedback + +[English](README.md) | 中文 + +面向用户的 `/feedback` 采集。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。 + +## 命令契约 + +| 输入 | 结果 | +|---|---| +| `/feedback ` | 以 `Feedback recorded.` 确认。注册表的 `command/run` 记录携带原样文本。 | +| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | + +反馈文本从不被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令会各自产生自己的记录,不会替换或合并。 + +## 本插件做什么、不做什么 + +该命令记录一条评价,不做别的事。它不追加属于自己的会话事件,不启动任何模型工作,本仓库中也没有任何插件读取它的记录。 + +记录来自命令注册表自身的 `command/run` / `command/done` 配对,由 [`dsh-commands`](../../ui/commands/README.md) 为每个已分发命令追加。这些追加会启动持久化的常规即时排空;注册表与本命令都不会强制 `session/flush`,因此确认文本表示条目已进入日志,而不表示它已经落盘。`command/run` 携带命令名、原样未解析的后缀以及调用来源;配对的 `command/done` 携带结果。两者都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及任何模型请求中。被拒绝的空输入仍会留下该配对,并以 `kind: 'error'` 结算,因此任何条目都不会被误认为已接受的反馈。 + +曾考虑并否决了专用的 `session/feedback` 事件:它会重复注册表已经写入的记录,而消费方可以依据注册表已存储的命令名筛选反馈。 + +## 组合 + +生产方只注入 `commands`。自定义应用挂载注册表以及本插件: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: command-feedback + name: '@deepseek-ai/dsh-command-feedback' +``` + +TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal 栈。无头 CLI、ACP 自动化和 JSON-RPC 适配器不消费 `ctx.commands`,因此不会暴露它。 + +## 模型体验 + +### 用户 `/feedback` 采集 + +#### 模型看到的内容 + +无。斜杠输入、被记录的文本以及确认文本都不出现在模型请求中。注册表的 `command/run` 与 `command/done` 记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。 + +#### Token 影响 + +无直接 token 影响。无论是已接受的条目还是用法错误,都不会在记录所在轮次或此后任何轮次增加模型 token。 + +#### KV Cache 影响 + +与模型请求路径无关。记录只追加到会话日志,不触碰已经可复用的请求前缀。本包贡献的任何内容都不会使缓存复用失效。 + +## 已知限制与暂缓工作 + +- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取它;消费方是另一个依据命令名筛选 `command/run` 记录的独立包。 +- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 +- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 +- **记录中的文本未修剪**:处理器只为校验而修剪;`command/run` 存储原始后缀,包含其前导分隔空白,因此消费方需在读取时修剪。 +- **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 +- **随附应用中只有 TUI 使用此命令**:无头 CLI、ACP 自动化和 JSON-RPC 适配器不挂载 `ctx.commands`,因此 `/feedback` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json new file mode 100644 index 0000000000..6ad91d0e0d --- /dev/null +++ b/packages/feedback/command-feedback/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-command-feedback", + "description": "Human-facing slash command that records session feedback as a log-only event", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts new file mode 100644 index 0000000000..7bf7cd0853 --- /dev/null +++ b/packages/feedback/command-feedback/src/index.ts @@ -0,0 +1,40 @@ +/** + * Human-facing `/feedback` command. It records a remark about the session and + * does nothing else: the command registry's own `command/run` and + * `command/done` events are the whole record, so this plugin only validates the + * input and acknowledges it. Those appends are eager but unflushed, so the + * acknowledgement reports the entry is logged, not that it reached disk. + * @module @deepseek-ai/dsh-command-feedback + */ + +import type { Context } from 'cordis' +import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' + +export const name = 'command-feedback' +export const inject = ['commands'] + +const USAGE = 'Usage: /feedback ' + +/** + * Validate and acknowledge one feedback entry. `command/run` already carries + * the verbatim text, so no further append is needed; returning an error instead + * settles that record as `kind: 'error'` and leaves no accepted feedback. + * @param invocation - receiving agent, raw command input, and UI cancellation. + * @returns an acknowledgement, or a usage error when no feedback text was supplied. + */ +function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { + if (invocation.rawInput.trim().length === 0) { + return { kind: 'error', text: `Feedback text is required. ${USAGE}` } + } + return { kind: 'success', text: 'Feedback recorded.' } +} + +/** Register the global `/feedback` command for every composed command adapter. */ +export function apply(ctx: Context): void { + ctx.commands.register({ + name: 'feedback', + description: 'record feedback about this session', + input: { hint: '' }, + handler: executeFeedbackCommand, + }) +} diff --git a/packages/feedback/command-feedback/src/invariant.ts b/packages/feedback/command-feedback/src/invariant.ts new file mode 100644 index 0000000000..72a3ead213 --- /dev/null +++ b/packages/feedback/command-feedback/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-command-feedback`. + * @module @deepseek-ai/dsh-command-feedback/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-command-feedback' + +/** Cordis companion plugin name. */ +export const name = 'command-feedback-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this command declares no session event and owns no state projection. The + * `command/run`/`command/done` pairing that records feedback belongs to `dsh-commands`. + */ +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/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts new file mode 100644 index 0000000000..362bb8ce30 --- /dev/null +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' +import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' + +interface Harness { + readonly ctx: Context + readonly agent: Agent + readonly session: Session + readonly plugin: Awaited> +} + +/** Build a live idle agent over a store-owned session, as an app's spine does. */ +function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { + const session = ctx.sessions.create(SessionId(id)) + let status: AgentStatus = 'idle' + const agent: Agent = { + id: session.id, + options: {}, + session, + ctx: new Context(), + get status() { return status }, + get acceptsNextStep() { return status === 'running' }, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel() { status = 'idle' }, + whenIdle() { return Promise.resolve() }, + } + return { agent, session } +} + +/** Mount the real command registry and this producer. */ +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionStore) + const plugin = await ctx.plugin(commandFeedback) + const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`) + ctx.agents.register(agent) + return { ctx, agent, session, plugin } +} + +/** Execute `/feedback` through the same registry boundary as a UI adapter. */ +async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> { + const settled = await test.ctx.commands.execute( + test.agent, + `/feedback${suffix}`, + new AbortController().signal, + ) + if (settled === undefined) throw new Error('feedback command was not registered') + return settled.result +} + +/** The registry's durable record of each accepted command, in log order. */ +function commandRecords(session: Session): { name: string; args: string; kind: string }[] { + const runs = session.events.filter(event => event.type === 'command/run') + return runs.map((event) => { + const done = session.events.find(item => + item.type === 'command/done' && item.data.commandId === event.data.commandId) + if (done?.type !== 'command/done') throw new Error('every command/run must be paired') + return { name: event.data.name, args: event.data.args, kind: done.data.kind } + }) +} + +describe('@deepseek-ai/dsh-command-feedback registration', () => { + it('registers one global command with Loader-safe exports and disposes it', async () => { + const test = await harness() + expect(commandFeedback.name).toBe('command-feedback') + expect(commandFeedback.inject).toEqual(['commands']) + expect('default' in commandFeedback).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback) + + expect(test.ctx.commands.list(test.agent)).toContainEqual({ + name: 'feedback', + description: 'record feedback about this session', + input: { hint: '' }, + }) + expect(test.ctx.commands.find(test.agent, 'feedback')).toBeDefined() + + await test.plugin.dispose() + expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined() + }) +}) + +describe('/feedback human command', () => { + it('acknowledges feedback and leaves the registry record as its durable trace', async () => { + const test = await harness() + await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ + kind: 'success', + text: 'Feedback recorded.', + }) + expect(commandRecords(test.session)).toEqual([ + { name: 'feedback', args: ' the diff view is unreadable', kind: 'success' }, + ]) + }) + + it('adds no event of its own beyond the registry pairing', async () => { + const test = await harness() + await run(test, ' nothing else happens') + // The whole point of the command: record and do nothing. Only the + // registry's own pairing appears, and no turn of model work starts. + expect(test.session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) + }) + + it('records verbatim text, including input that looks like another command', async () => { + const test = await harness() + await run(test, ' /plan felt SLOW\n\ttwice today ') + expect(commandRecords(test.session)).toEqual([ + { name: 'feedback', args: ' /plan felt SLOW\n\ttwice today ', kind: 'success' }, + ]) + }) + + it('records each entry separately without replacing earlier ones', async () => { + const test = await harness() + await run(test, ' first') + await run(test, ' second') + expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second']) + }) + + it('records concurrent submissions in dispatch order', async () => { + const test = await harness() + const signal = new AbortController().signal + // The shipped TUI dispatches commands fire-and-forget. + const settled = await Promise.all([ + test.ctx.commands.execute(test.agent, '/feedback first', signal), + test.ctx.commands.execute(test.agent, '/feedback second', signal), + ]) + expect(settled.map(item => item?.result)).toEqual([ + { kind: 'success', text: 'Feedback recorded.' }, + { kind: 'success', text: 'Feedback recorded.' }, + ]) + expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second']) + }) + + it('keeps every recorded event off the model surface and out of derived history', async () => { + const test = await harness() + await run(test, ' invisible to the model') + for (const event of test.session.events) { + expect('surfaceOp' in event).toBe(false) + expect(test.session.deriveEventMessage(event)).toBeNull() + } + expect(foldSurface(test.session.events).nodes).toEqual([]) + expect(test.session.surface.nodes).toEqual([]) + expect(test.session.deriveMessages()).toEqual([]) + }) + + it('rejects empty and whitespace-only input as a failed command record', async () => { + const test = await harness() + const expected = { + kind: 'error', + text: 'Feedback text is required. Usage: /feedback ', + } + await expect(run(test)).resolves.toEqual(expected) + await expect(run(test, ' \n\t ')).resolves.toEqual(expected) + // Rejected input still leaves the registry's own pairing, settled as an + // error, so no entry is mistaken for accepted feedback. + expect(commandRecords(test.session).map(record => record.kind)).toEqual(['error', 'error']) + }) + + it('records nothing when dispatch rejects an already-cancelled request', async () => { + const test = await harness() + const controller = new AbortController() + controller.abort(new Error('user cancelled the command')) + await expect(test.ctx.commands.execute(test.agent, '/feedback too late', controller.signal)) + .rejects.toThrow('user cancelled the command') + expect(test.session.events).toEqual([]) + }) +}) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..9aa206f9ad --- /dev/null +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -0,0 +1,105 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +/** Register one idle agent over a store-owned session, as an app's spine does. */ +function agent(ctx: Context): Agent { + const scope = ctx.plugin(() => {}) + const id = SessionId('feedback-loader-agent') + const session = ctx.sessions.create(id) + let status: AgentStatus = 'idle' + const value: Agent = { + id, + options: {}, + session, + ctx: scope.ctx, + get status() { return status }, + get acceptsNextStep() { return status === 'running' }, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel() { status = 'idle' }, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +describe('/feedback real Loader composition through cordis.yml', () => { + it('boots cordis.yml and records feedback without model-visible output', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-commands'", + "- name: '@deepseek-ai/dsh-command-feedback'", + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-commands', CommandService], + ['@deepseek-ai/dsh-command-feedback', CommandFeedback], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context) + const signal = new AbortController().signal + + // Discoverable through the composed registry, as a UI adapter finds it. + expect(context.commands.list(owner).map(command => command.name)).toContain('feedback') + + const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal) + expect(accepted?.result).toEqual({ kind: 'success', text: 'Feedback recorded.' }) + const rejected = await context.commands.execute(owner, '/feedback', signal) + expect(rejected?.result).toEqual({ + kind: 'error', + text: 'Feedback text is required. Usage: /feedback ', + }) + + // The command records itself through the registry and does nothing else. + expect(owner.session.events.map(event => event.type)) + .toEqual(['command/run', 'command/done', 'command/run', 'command/done']) + const run = owner.session.events.find(event => event.type === 'command/run') + expect(run?.type === 'command/run' && run.data.args).toBe(' the diff view is unreadable') + + // Nothing reached the model. + expect(owner.session.deriveMessages()).toEqual([]) + expect(owner.session.surface.nodes).toEqual([]) + }) +}) diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json new file mode 100644 index 0000000000..6a27b54d3a --- /dev/null +++ b/packages/feedback/command-feedback/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18cb1ec0d1..38e1c9eef5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2303,6 +2303,9 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../agent-spine-demo + '@deepseek-ai/dsh-command-feedback': + specifier: workspace:^ + version: link:../../feedback/command-feedback '@deepseek-ai/dsh-command-goal': specifier: workspace:^ version: link:../../goal/command-goal @@ -2358,6 +2361,33 @@ importers: specifier: ^3.17.0 version: 3.18.0 + packages/feedback/command-feedback: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 42198d4eea..7a67ce8bac 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 870 + "packages/README.md": 880 } diff --git a/tsconfig.base.json b/tsconfig.base.json index 00c19c4b8c..b87f867107 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -72,6 +72,7 @@ "./packages/compact/*/src/invariant.ts", "./packages/context/*/src/invariant.ts", "./packages/goal/*/src/invariant.ts", + "./packages/feedback/*/src/invariant.ts", "./packages/guard/*/src/invariant.ts", "./packages/plan/*/src/invariant.ts", "./packages/subagent/*/src/invariant.ts", @@ -161,6 +162,7 @@ "./packages/compact/*/src", "./packages/context/*/src", "./packages/goal/*/src", + "./packages/feedback/*/src", "./packages/guard/*/src", "./packages/plan/*/src", "./packages/subagent/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index e2112b7f6a..5c1a7488c0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -83,6 +83,7 @@ { "path": "./packages/goal/tool-goal" }, { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, + { "path": "./packages/feedback/command-feedback" }, { "path": "./packages/context/time-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, From eb6fa864813f6513a037e79b4dbacf9d4477338d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:33:53 +0800 Subject: [PATCH 002/130] fix(feedback): keep payload in feedback event --- .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 20 +++--- .../feature/2026-07-28-feedback-command.zh.md | 20 +++--- ...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/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/commands.i18n.yaml | 6 +- docs/core-data-structures/commands.md | 6 ++ docs/core-data-structures/commands.zh.md | 6 ++ docs/event-producer-consumer.md | 2 +- docs/persistence-catalog.md | 23 +++++-- .../client/connection/src/client/fixture.ts | 12 ++-- .../src/client/sessions/conversation.ts | 5 +- .../src/client/sessions/fold-adapter.ts | 4 +- packages/client/runtime/tests/event-script.ts | 2 + .../client/runtime/tests/fold-adapter.spec.ts | 8 +++ .../src/client/chat/GenericCommandCard.tsx | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/feedback/README.i18n.yaml | 4 +- packages/feedback/README.md | 4 +- packages/feedback/README.zh.md | 4 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 17 +++-- .../feedback/command-feedback/README.zh.md | 17 +++-- .../feedback/command-feedback/package.json | 3 +- .../feedback/command-feedback/src/index.ts | 39 +++++++++--- .../command-feedback/src/invariant.ts | 4 +- .../tests/command-feedback.spec.ts | 62 +++++++++++-------- .../tests/loader-composition.spec.ts | 9 ++- .../feedback/command-feedback/tsconfig.json | 3 + packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 2 +- packages/plan/plan-mode/README.zh.md | 2 +- packages/plan/plan-mode/src/index.ts | 1 + .../plan/plan-mode/tests/projection.spec.ts | 11 +++- packages/ui/commands/README.i18n.yaml | 4 +- packages/ui/commands/README.md | 4 +- packages/ui/commands/README.zh.md | 4 +- packages/ui/commands/src/index.ts | 17 ++++- packages/ui/commands/tests/commands.spec.ts | 19 ++++++ 42 files changed, 248 insertions(+), 130 deletions(-) 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 ba56da8945..7a429953d8 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: ae32d3908d568c4a511e8d9e2b8cf50569fb80bf -2026-07-28-feedback-command.zh.md: f69dbf6a50161e7f5048b76be46bc4063f9e757a +2026-07-28-feedback-command.md: 1c093d0e37eb72dc66e3c5569bd642557dde56a1 +2026-07-28-feedback-command.zh.md: 300946a71ac7485a4bc787dd70ae5357147627f3 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 ae32d3908d..1c093d0e37 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -14,15 +14,15 @@ The capture surface has to be usable at the moment of annoyance, which rules out `@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback ` acknowledges; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. -The plugin appends **no session event of its own**. `dsh-commands` already writes a `command/run` / `command/done` pair for every dispatched command, carrying the command name, the verbatim unparsed suffix, the invocation source, and the settled outcome. Those records are log-only and non-surface, so the feedback lands in the session log and stays invisible to the model without this package contributing anything to the log format. The appends start persistence's ordinary eager drain; nothing forces a flush, so the acknowledgement reports that the entry is recorded in the log rather than already on disk. +The package declares the log-only `feedback/record { text }` session event and exports `recordFeedback(session, text)` as its command-independent producer. The producer discards surrounding whitespace, rejects an empty result, and appends exactly one event. `/feedback` delegates to it, so another UI, hook, or host integration can record the same domain fact without constructing a slash command. -Capture is deliberately inert: nothing in this repository reads those records back. +`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. -### Why no dedicated `session/feedback` event +Capture is deliberately inert: nothing in this repository reads `feedback/record`. -An earlier iteration declared one. It was removed because it duplicated a record the registry already writes: both would carry the same text, appended microseconds apart, and a consumer would have to decide which is authoritative. Selecting `command/run` records by command name is enough to find feedback, and it keeps this package free of the session event format entirely — no `SessionEventMap` merge, no invariant relation, no persistence catalog entry. +### Why feedback owns an event -The cost is that the recorded text is the raw suffix including its leading separator whitespace, and that feedback is distinguished from other commands only by name. Both are read-time concerns for a consumer that does not yet exist; neither justifies a second durable record now. +Feedback is a domain fact, while `/feedback` is one trigger. Keeping the only payload in `feedback/record` lets later triggers use the same event and lets consumers select feedback without depending on command names or parsing command lifecycle records. Omitting `command/run.args` for this definition avoids two authoritative-looking copies of one human remark. ### Why the model never sees it @@ -30,7 +30,7 @@ Feedback is about the session, not input to it. Injecting it as a user message w ### Verbatim text -Nothing is parsed. `/feedback /plan felt slow` records that literal text; the leading `/plan` is content, not a nested command. The handler trims only to decide whether any text was supplied. Control-word grammar of the kind `/goal` uses would make the corresponding literal feedback impossible to express, which is the opposite of what a capture surface is for. +Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /plan felt slow` records `/plan felt slow`; the leading `/plan` is content, not a nested command. Control-word grammar of the kind `/goal` uses would make the corresponding literal feedback impossible to express, which is the opposite of what a capture surface is for. ### A new group @@ -38,7 +38,9 @@ Nothing is parsed. `/feedback /plan felt slow` records that literal text; the le ## Alternatives considered -**Declare a dedicated `session/feedback` log-only event.** Implemented first, then removed. It gave feedback a first-class queryable type with pre-trimmed text, but duplicated the registry's record, added a `SessionEventMap` member and persistence-catalog entry to the frozen log format, and created two records of one act with no rule for which wins. +**Use `command/run` as the feedback record.** Rejected because feedback would then be coupled to one trigger and consumers would have to identify a domain fact by command name. A non-command producer could not create the same record without pretending to execute a command. + +**Store the text in both `feedback/record` and `command/run.args`.** Rejected because one act would have two payload copies with no useful distinction. `recordInput: false` preserves the generic lifecycle while leaving the domain event authoritative. **Inject feedback as a user message via `agent.inject()`.** Needs no new event type and reuses the path `/goal` mutations take. Rejected: it makes the feedback model-visible, so it enters the next request, changes the run being commented on, and consumes tokens — contradicting all three parts of the no-perturbation requirement. @@ -54,8 +56,8 @@ Nothing is parsed. `/feedback /plan felt slow` records that literal text; the le The TUI mounts the command unconditionally — no configuration, no dependency on the goal stack. The headless CLI, ACP, and JSON-RPC apps do not consume `ctx.commands`, so `/feedback` is unavailable there. -This package is now small enough that its whole contract is the command definition plus one validation branch. It owns no session event, so it needs no invariant relation and cannot affect replay, forking, or crash recovery. +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; the recorded text is untrimmed, so a consumer trims at read time; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. +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. 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 f69dbf6a50..300946a71a 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 @@ -14,15 +14,15 @@ Status: implemented 位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback ` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。 -该插件**不追加属于自己的会话事件**。`dsh-commands` 已经为每个已分发命令写入一对 `command/run` / `command/done`,携带命令名、原样未解析的后缀、调用来源以及结算结果。这些记录仅写入日志且非 surface,因此反馈会进入会话日志并对模型保持不可见,而本包无需向日志格式贡献任何内容。这些追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是条目已记录在日志中,而非已经落盘。 +本包(package)声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。 -采集刻意不产生后续动作:本仓库中没有任何代码读回这些记录。 +`dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会启动持久化的常规即时排空;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 -### 为何不设专用的 `session/feedback` 事件 +采集刻意不产生后续动作:本仓库中没有任何代码读取 `feedback/record`。 -早先的实现声明过该事件,后来将其移除,因为它重复了注册表已经写入的记录:两者会携带相同文本、相隔极短时间先后追加,而消费方还得判断以哪一条为准。依据命令名筛选 `command/run` 记录已足以找到反馈,同时让本包完全不涉及会话事件格式——没有 `SessionEventMap` 合并、没有不变式关系、没有持久化目录条目。 +### 为何反馈拥有自己的事件 -代价是被记录的文本为原始后缀,包含其前导分隔空白;且反馈仅凭命令名与其他命令相区分。两者都属于尚不存在的消费方在读取时需要处理的问题,目前都不足以支撑再增加一条持久记录。 +反馈是领域事实,而 `/feedback` 是一种触发方式。只把载荷保存在 `feedback/record` 中,既让后续触发方式可以使用同一个事件,也让消费方无需依赖命令名或解析命令生命周期记录即可筛选反馈。在该定义中省略 `command/run.args`,可避免同一条人类评价出现两个看起来都具有权威性的副本。 ### 为何模型永不看到它 @@ -30,7 +30,7 @@ Status: implemented ### 原样文本 -不做任何解析。`/feedback /plan felt slow` 记录的就是该字面文本;开头的 `/plan` 是内容,而非嵌套命令。处理器仅为判断是否提供了文本而修剪。若采用 `/goal` 那样的控制词语法,对应的字面反馈将无法表达,这与采集接口的目的正好相反。 +前后空白会被丢弃,但除此之外不做解析。`/feedback /plan felt slow` 记录 `/plan felt slow`;开头的 `/plan` 是内容,而非嵌套命令。若采用 `/goal` 那样的控制词语法,对应的字面反馈将无法表达,这与采集接口的目的正好相反。 ### 一个新的分组 @@ -38,7 +38,9 @@ Status: implemented ## 考虑过的替代方案 -**声明专用的 `session/feedback` 仅日志事件。** 先实现后移除。它让反馈拥有一等的可查询类型和预先修剪的文本,但重复了注册表的记录,向已冻结的日志格式新增了一个 `SessionEventMap` 成员与持久化目录条目,并使同一行为产生两条记录而没有取舍规则。 +**使用 `command/run` 作为反馈记录。** 已否决,因为这会将反馈与一种触发方式耦合,消费方还必须通过命令名识别领域事实。非命令生产方若不伪装成执行命令,就无法创建相同记录。 + +**同时在 `feedback/record` 与 `command/run.args` 中存储文本。** 已否决,因为同一行为会产生两个没有实质区别的载荷副本。`recordInput: false` 保留通用生命周期,同时让领域事件保持权威性。 **通过 `agent.inject()` 将反馈作为 user 消息注入。** 无需新增事件类型,并复用 `/goal` 变更所走的路径。已否决:它会让反馈对模型可见,从而进入下一次请求、改变正被评论的那次运行并消耗 token——与「不得扰动」要求的三个方面全部冲突。 @@ -54,8 +56,8 @@ Status: implemented TUI 无条件挂载该命令:没有配置,也不依赖 goal 栈。无头 CLI、ACP 和 JSON-RPC 应用不消费 `ctx.commands`,因此 `/feedback` 在那里不可用。 -本包现已小到其全部契约就是命令定义加一个校验分支。它不拥有任何会话事件,因此无需不变式关系,也不可能影响回放、fork 或崩溃恢复。 +本包拥有一个独立的仅追加事件,不存在跨事件关系或可变数据关系可供不变式伴生插件检查。该事件遵循会话日志现有的回放、fork、持久化和崩溃尾部行为。 -延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;被记录的文本未修剪,需由消费方在读取时处理;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 +延期事项:没有消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone;且没有显式持久化屏障,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。 本次变更不附带 snapshot。AGENTS.md 要求面向产品用户的可见行为变更通过可运行示例附带无密钥 snapshot;此项按请求者的明确指示跳过。包测试连同一个基于真实 `cordis.yml` 的 Loader 组合测试即为全部证据,此外还有在组装后 TUI 中的交互验证。 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 8f720e33b5..49c96e63a3 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: 500f07968db049e4a174ff3b7a075bfe095283db +2026-07-27-session-projection-and-command-log.md: 6ffdae7df9e908356c972f077d66b831f3b6a1ff +2026-07-27-session-projection-and-command-log.zh.md: d2a40fb72245df413c7790932a41ddea3be7902d 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..6ffdae7df9 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 @@ -115,11 +115,11 @@ The one existing violation of "no hooks through inject" — `DetailsInjected.use Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`tool/result` pairing: ```ts ignore-check -'command/run': { commandId: string; name: string; args: string; source: CommandSource } +'command/run': { commandId: string; name: string; args?: string; source: CommandSource } 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } ``` -The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged. +The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and, by default, `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. A definition sets `recordInput: false` when its authoritative domain event owns the payload; `command/run` then omits `args` rather than duplicating it. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged. Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to admission — `{ matched, commandId? }`: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. The one-shot notice channel (`runDetached` → `noticeFor`) is retired. 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 500f07968d..d2a40fb722 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 @@ -115,11 +115,11 @@ type UseProjection = { 两个仅日志(非 surface、模型不可见)事件,镜像 `tool/call`/`tool/result` 的配对: ```ts ignore-check -'command/run': { commandId: string; name: string; args: string; source: CommandSource } +'command/run': { commandId: string; name: string; args?: string; source: CommandSource } 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } ``` -host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 与 `args` 就是解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。 +host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 以及默认携带的 `args` 来自解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。当载荷由权威领域事件持有时,命令定义会设置 `recordInput: false`;此时 `command/run` 省略 `args`,而不是重复该载荷。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。 由于已提交事件会在 mux 流上广播,刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`:该行是否匹配命中,以及命中时新铸的配对 id,发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道(`runDetached` → `noticeFor`)就此下线。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 967241fbf6..54291934fd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -420,7 +420,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:161`](../../packages/ui/commands/src/index.ts) ## `domain/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e0dd17fa02..ffd80cc6e5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -442,7 +442,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise CommandResult | Promise } diff --git a/docs/core-data-structures/commands.zh.md b/docs/core-data-structures/commands.zh.md index 1a51305df3..f90e7c93c4 100644 --- a/docs/core-data-structures/commands.zh.md +++ b/docs/core-data-structures/commands.zh.md @@ -31,6 +31,12 @@ interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor + /** + * Whether `command/run` records `rawInput`. Defaults to true. A command + * whose domain event owns the payload sets this false to avoid duplicating + * that payload in the session log. + */ + readonly recordInput?: boolean /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9d66b52bfb..a169747843 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -24,7 +24,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:161`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7c027afd68..7c4dd1010b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -185,7 +185,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } ``` -Source: [`packages/ui/commands/src/index.ts:138`](../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:145`](../packages/ui/commands/src/index.ts) #### `command/run` — log-only @@ -197,12 +197,13 @@ Source: [`packages/ui/commands/src/index.ts:138`](../packages/ui/commands/src/in * and `args` are `parseCommand`'s own split (name and verbatim rawInput, * separator whitespace included), so a consumer (a projection unit * folding its own command records, a rich command card) never re-parses - * a line. + * a line. `args` is absent when the definition sets `recordInput: false` + * because an authoritative domain event owns the input payload. */ -'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource } +'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/index.ts) ### `compact/*` @@ -256,6 +257,20 @@ Types: [ContentBlock](core-data-structures/core.md) Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) +### `feedback/*` + +#### `feedback/record` — log-only + +```ts persistence-catalog +/** + * One recorded human remark about this session. Log-only and independent + * of its trigger; it never enters the model surface or derived history. + */ +'feedback/record': { text: string } +``` + +Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedback/command-feedback/src/index.ts) + ### `hook/*` #### `hook/invoked` — log-only diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index be9ba79347..5d5e34bf31 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -304,9 +304,9 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi /** * Fixture parallel of the plan unit's double-event fold: `command/run` - * records named `plan` set the wanted target (`off` → false, else true); - * `plan/mode` commits and clears it. `wanted` is exposed for the prompt - * boundary (the fixture's agent/step parallel). + * records named `plan` with recorded input set the wanted target (`off` → + * false, else true); `plan/mode` commits and clears it. `wanted` is exposed + * for the prompt boundary (the fixture's agent/step parallel). */ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { let active = false @@ -315,7 +315,8 @@ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boo const item = event as unknown as { type: string; data?: Record } if (item.type === 'command/run' && item.data?.['name'] === 'plan') { const args = item.data['args'] - wanted = (typeof args === 'string' ? args : '').trim() !== 'off' + if (typeof args !== 'string') continue + wanted = args.trim() !== 'off' } else if (item.type === 'plan/mode') { active = item.data?.['active'] === true wanted = null @@ -374,8 +375,9 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: }] } // The plan unit advances on its two folded event kinds. + const commandData = event as unknown as { data: { name?: string; args?: unknown } } if (type === 'plan/mode' || (type === 'command/run' - && (event as unknown as { data: { name?: string } }).data.name === 'plan')) { + && commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) { return [{ type: 'session/projection', sessionId: id, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f5f0717236..474f7cc8b3 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -140,7 +140,10 @@ export interface CommandNode { commandId: CommandId /** Command name (run payload's structured field); null when the run fell outside the window. */ name: string | null - /** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */ + /** + * Verbatim rawInput after the name, including separator whitespace; null + * when omitted by the command or when the run fell outside the window. + */ args: string | null /** Settlement outcome (done payload); null while the command is still executing. */ outcome: { kind: 'success' | 'error'; text?: string } | null diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 039c36056b..c884d2ec8e 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -234,10 +234,10 @@ export class FoldAdapter { // enter the client program, so this wire consumer narrows structurally // (the same posture as tool/code-dispatch in session.ts). if ((event.type as string) === 'command/run') { - const data = event.data as unknown as { commandId: CommandId; name: string; args: string } + const data = event.data as unknown as { commandId: CommandId; name: string; args?: string } this.commandIdx.set(data.commandId, { kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: data.name, args: data.args, outcome: null, + commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null, }) return } diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 53f80e0e69..7da9bf0184 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -67,6 +67,8 @@ export const ev = { at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), + commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent => + at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }), commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }), } diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index b40bdb4111..0b14fda5c3 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -195,6 +195,14 @@ describe('FoldAdapter', () => { }) }) + it('represents command input omitted by the host as null', () => { + const adapter = new FoldAdapter() + adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')], 0) + expect(adapter.nodes().nodes[0]).toMatchObject({ + kind: 'command', name: 'feedback', args: null, outcome: null, + }) + }) + it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => { const adapter = new FoldAdapter() adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80) diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 1dfea5488b..1d6db2581d 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -21,8 +21,8 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) { ? '执行中…' : text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成') // Display line rebuilt from the structured payload (args carries its own - // separator whitespace verbatim); a cross-window node whose run page fell - // out of the window has neither. + // separator whitespace verbatim); omitted input and a cross-window node + // whose run page fell out both render without it. const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}` return ( CommandResult | Promise;\n}', + declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly recordInput?: boolean;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', }, { name: 'CommandDescriptor', diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml index eca3b1c420..31ed2d25e8 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: ab7bc6f3e3a3be0c280855ff80e92c7d7a7e665e -README.zh.md: 9c050ac42aa468895c04124a76a3bce58756df0e +README.md: 7962a16ee9bc7d8a969a466591d761829cd55d7f +README.zh.md: aad8f4d797ff16a5ef9be4c968fb28d708bad13e diff --git a/packages/feedback/README.md b/packages/feedback/README.md index ab7bc6f3e3..7962a16ee9 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -6,6 +6,6 @@ The feedback family lets a human record a remark about the session without actin | Package | Role | ctx key | |---|---|---| -| `command-feedback/` | Human-facing `/feedback` command recorded through the command plane | — | +| `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 the command records 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, and no shipped plugin consumes it. A future consumer reads `feedback/record` events from the session log rather than changing how they are captured. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md index 9c050ac42a..aad8f4d797 100644 --- a/packages/feedback/README.zh.md +++ b/packages/feedback/README.zh.md @@ -6,6 +6,6 @@ feedback 家族让人类记录对会话的评价,但不据此采取任何动 | 包 | 职责 | ctx 键 | |---|---|---| -| `command-feedback/` | 面向用户的 `/feedback` 命令,通过命令平面完成记录 | 无 | +| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | -被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取命令记录,而不是改变它们的采集方式。 +被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取 `feedback/record` 事件,而不是改变它们的采集方式。 diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index 37f10ac485..47c169ec3f 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: 90992b7295536a9099766910f616e640d4b4bcfe -README.zh.md: a7c4f03997cea182ed24dcfc7f309dc3bd872d5e +README.md: c9650d6a2c595550545b3dbf07f62e6aa65f39b9 +README.zh.md: ba24276ba1bd71a4eb68c7fdb48a3760bdbec8fc diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index 90992b7295..c9650d6a2c 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -2,24 +2,24 @@ English | [中文](README.zh.md) -Human-facing `/feedback` capture. The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. +Trigger-independent session feedback plus human-facing `/feedback` capture. The package exports `recordFeedback(session, text)`, which appends one log-only `feedback/record` event. Its plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. ## Command contract | Input | Result | |---|---| -| `/feedback ` | Acknowledge with `Feedback recorded.` The registry's `command/run` record carries the verbatim text. | +| `/feedback ` | Append `feedback/record` and acknowledge with `Feedback recorded.` | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | -Feedback text is never parsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own record; nothing is replaced or merged. +Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. ## What this plugin does and does not do -The command records a remark and does nothing else. It appends no session event of its own, starts no model work, and no plugin in this repository reads its records. +`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. -The record is the command registry's own `command/run` / `command/done` pairing, which [`dsh-commands`](../../ui/commands/README.md) appends for every dispatched command. Those appends start persistence's ordinary eager drain; neither the registry nor this command forces a `session/flush`, so the acknowledgement means the entry is in the log, not that it has already reached disk. `command/run` carries the command name, the verbatim unparsed suffix, and the invocation source; the paired `command/done` carries the outcome. Both are log-only and are absent from the ordered surface, from `deriveMessages()`, and from every model request. A rejected empty input still leaves that pairing, settled as `kind: 'error'`, so no entry can be mistaken for accepted feedback. +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`. -A dedicated `session/feedback` event was considered and rejected: it would duplicate a record the registry already writes, and a consumer can select feedback by the command name it already stores. +The event is authoritative rather than the command record because feedback may arrive through a trigger other than `/feedback`. Keeping the payload out of `command/run` avoids two records carrying the same text. ## Composition @@ -40,7 +40,7 @@ The TUI app mounts this command unconditionally; it has no configuration and no #### What the model sees -Nothing. The slash input, the recorded text, and the acknowledgement are all absent from model requests. The registry's `command/run` and `command/done` records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests. +Nothing. The slash input, `feedback/record`, and the acknowledgement are absent from model requests. The feedback event and registry lifecycle records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests. #### Token effect @@ -52,9 +52,8 @@ 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 it; a consumer is a separate package that selects `command/run` records by command name. +- **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 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. -- **Untrimmed text in the record** — the handler trims only to validate; `command/run` stores the raw suffix, including its leading separator whitespace, so a consumer trims at read time. - **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)`. - **TUI only in the shipped apps** — the headless CLI, ACP automation, and JSON-RPC adapters do not mount `ctx.commands`, so `/feedback` is unavailable there. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index a7c4f03997..ba24276ba1 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -2,24 +2,24 @@ [English](README.md) | 中文 -面向用户的 `/feedback` 采集。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。 +与触发方式无关的会话反馈,以及面向用户的 `/feedback` 采集。本包(package)导出 `recordFeedback(session, text)`,后者追加一个仅写入日志的 `feedback/record` 事件。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。 ## 命令契约 | 输入 | 结果 | |---|---| -| `/feedback ` | 以 `Feedback recorded.` 确认。注册表的 `command/run` 记录携带原样文本。 | +| `/feedback ` | 追加 `feedback/record`,并以 `Feedback recorded.` 确认。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | -反馈文本从不被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令会各自产生自己的记录,不会替换或合并。 +前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 ## 本插件做什么、不做什么 -该命令记录一条评价,不做别的事。它不追加属于自己的会话事件,不启动任何模型工作,本仓库中也没有任何插件读取它的记录。 +`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,不启动任何模型工作;本仓库中也没有任何插件读取该事件。 -记录来自命令注册表自身的 `command/run` / `command/done` 配对,由 [`dsh-commands`](../../ui/commands/README.md) 为每个已分发命令追加。这些追加会启动持久化的常规即时排空;注册表与本命令都不会强制 `session/flush`,因此确认文本表示条目已进入日志,而不表示它已经落盘。`command/run` 携带命令名、原样未解析的后缀以及调用来源;配对的 `command/done` 携带结果。两者都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及任何模型请求中。被拒绝的空输入仍会留下该配对,并以 `kind: 'error'` 结算,因此任何条目都不会被误认为已接受的反馈。 +反馈文本只出现在一个持久载荷中:`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`。 -曾考虑并否决了专用的 `session/feedback` 事件:它会重复注册表已经写入的记录,而消费方可以依据注册表已存储的命令名筛选反馈。 +权威记录是该事件,而不是命令记录,因为反馈可能来自 `/feedback` 之外的触发方式。让载荷不进入 `command/run`,可避免两条记录携带相同文本。 ## 组合 @@ -40,7 +40,7 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal #### 模型看到的内容 -无。斜杠输入、被记录的文本以及确认文本都不出现在模型请求中。注册表的 `command/run` 与 `command/done` 记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。 +无。斜杠输入、`feedback/record` 以及确认文本都不出现在模型请求中。反馈事件和注册表生命周期记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。 #### Token 影响 @@ -52,9 +52,8 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal ## 已知限制与暂缓工作 -- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取它;消费方是另一个依据命令名筛选 `command/run` 记录的独立包。 +- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取 `feedback/record`;消费方是另一个独立包。 - **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 - **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 -- **记录中的文本未修剪**:处理器只为校验而修剪;`command/run` 存储原始后缀,包含其前导分隔空白,因此消费方需在读取时修剪。 - **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 - **随附应用中只有 TUI 使用此命令**:无头 CLI、ACP 自动化和 JSON-RPC 适配器不挂载 `ctx.commands`,因此 `/feedback` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 6ad91d0e0d..25bc8446c3 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-command-feedback", - "description": "Human-facing slash command that records session feedback as a log-only event", + "description": "Log-only session feedback producer and human-facing slash command", "version": "0.0.1", "private": true, "type": "module", @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 7bf7cd0853..ae78b3cd4f 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -1,24 +1,45 @@ /** - * Human-facing `/feedback` command. It records a remark about the session and - * does nothing else: the command registry's own `command/run` and - * `command/done` events are the whole record, so this plugin only validates the - * input and acknowledges it. Those appends are eager but unflushed, so the - * acknowledgement reports the entry is logged, not that it reached disk. + * Session feedback event plus the human-facing `/feedback` producer. Recording + * appends one authoritative log-only event and does not start model work. The + * append is eager but unflushed, so acknowledgement reports that the entry is + * logged, not that it reached disk. * @module @deepseek-ai/dsh-command-feedback */ import type { Context } from 'cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import type { Session } from '@deepseek-ai/dsh-session' export const name = 'command-feedback' export const inject = ['commands'] const USAGE = 'Usage: /feedback ' +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * One recorded human remark about this session. Log-only and independent + * of its trigger; it never enters the model surface or derived history. + */ + 'feedback/record': { text: string } + } +} + /** - * Validate and acknowledge one feedback entry. `command/run` already carries - * the verbatim text, so no further append is needed; returning an error instead - * settles that record as `kind: 'error'` and leaves no accepted feedback. + * Record feedback independently of any UI trigger. + * @param session - session the feedback describes. + * @param text - human-authored feedback; surrounding whitespace is discarded. + * @throws {TypeError} when the normalized text is empty. + */ +export function recordFeedback(session: Session, text: string): void { + const normalized = text.trim() + if (normalized.length === 0) throw new TypeError('feedback text must not be empty') + session.append('feedback/record', { text: normalized }) +} + +/** + * Validate, record, and acknowledge one feedback entry. Returning an error + * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. * @returns an acknowledgement, or a usage error when no feedback text was supplied. */ @@ -26,6 +47,7 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { if (invocation.rawInput.trim().length === 0) { return { kind: 'error', text: `Feedback text is required. ${USAGE}` } } + recordFeedback(invocation.agent.session, invocation.rawInput) return { kind: 'success', text: 'Feedback recorded.' } } @@ -35,6 +57,7 @@ export function apply(ctx: Context): void { name: 'feedback', description: 'record feedback about this session', input: { hint: '' }, + recordInput: false, handler: executeFeedbackCommand, }) } diff --git a/packages/feedback/command-feedback/src/invariant.ts b/packages/feedback/command-feedback/src/invariant.ts index 72a3ead213..9c825a6e87 100644 --- a/packages/feedback/command-feedback/src/invariant.ts +++ b/packages/feedback/command-feedback/src/invariant.ts @@ -15,8 +15,8 @@ export const name = 'command-feedback-invariant' export const inject = ['invariants'] /** - * No runtime invariant: this command declares no session event and owns no state projection. The - * `command/run`/`command/done` pairing that records feedback belongs to `dsh-commands`. + * No runtime invariant: each `feedback/record` is an independent append-only + * fact with no cross-event or mutable-data relationship. */ const install: InvariantInstaller = () => {} diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 362bb8ce30..853e967176 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -58,15 +58,11 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s return settled.result } -/** The registry's durable record of each accepted command, in log order. */ -function commandRecords(session: Session): { name: string; args: string; kind: string }[] { - const runs = session.events.filter(event => event.type === 'command/run') - return runs.map((event) => { - const done = session.events.find(item => - item.type === 'command/done' && item.data.commandId === event.data.commandId) - if (done?.type !== 'command/done') throw new Error('every command/run must be paired') - return { name: event.data.name, args: event.data.args, kind: done.data.kind } - }) +/** Authoritative feedback payloads in log order. */ +function feedbackTexts(session: Session): string[] { + return session.events + .filter(event => event.type === 'feedback/record') + .map(event => event.data.text) } describe('@deepseek-ai/dsh-command-feedback registration', () => { @@ -83,7 +79,7 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => { description: 'record feedback about this session', input: { hint: '' }, }) - expect(test.ctx.commands.find(test.agent, 'feedback')).toBeDefined() + expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false }) await test.plugin.dispose() expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined() @@ -91,38 +87,47 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => { }) describe('/feedback human command', () => { - it('acknowledges feedback and leaves the registry record as its durable trace', async () => { + it('acknowledges feedback and records its payload exactly once in the domain event', async () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', text: 'Feedback recorded.', }) - expect(commandRecords(test.session)).toEqual([ - { name: 'feedback', args: ' the diff view is unreadable', kind: 'success' }, - ]) + expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) + const commandRun = test.session.events.find(event => event.type === 'command/run') + expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false) + expect(JSON.stringify(test.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) }) - it('adds no event of its own beyond the registry pairing', async () => { + it('exports a command-independent feedback producer', async () => { + const test = await harness() + commandFeedback.recordFeedback(test.session, ' recorded outside a command ') + expect(test.session.events.map(event => event.type)).toEqual(['feedback/record']) + expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) + expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') }) + .toThrow('feedback text must not be empty') + expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) + }) + + it('keeps command bookkeeping around the authoritative feedback event', async () => { const test = await harness() await run(test, ' nothing else happens') - // The whole point of the command: record and do nothing. Only the - // registry's own pairing appears, and no turn of model work starts. - expect(test.session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(test.session.events.map(event => event.type)).toEqual([ + 'command/run', 'feedback/record', 'command/done', + ]) }) - it('records verbatim text, including input that looks like another command', async () => { + it('normalizes surrounding whitespace without parsing command-like content', async () => { const test = await harness() await run(test, ' /plan felt SLOW\n\ttwice today ') - expect(commandRecords(test.session)).toEqual([ - { name: 'feedback', args: ' /plan felt SLOW\n\ttwice today ', kind: 'success' }, - ]) + expect(feedbackTexts(test.session)).toEqual(['/plan felt SLOW\n\ttwice today']) }) it('records each entry separately without replacing earlier ones', async () => { const test = await harness() await run(test, ' first') await run(test, ' second') - expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second']) + expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) it('records concurrent submissions in dispatch order', async () => { @@ -137,7 +142,7 @@ describe('/feedback human command', () => { { kind: 'success', text: 'Feedback recorded.' }, { kind: 'success', text: 'Feedback recorded.' }, ]) - expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second']) + expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) it('keeps every recorded event off the model surface and out of derived history', async () => { @@ -160,9 +165,12 @@ describe('/feedback human command', () => { } await expect(run(test)).resolves.toEqual(expected) await expect(run(test, ' \n\t ')).resolves.toEqual(expected) - // Rejected input still leaves the registry's own pairing, settled as an - // error, so no entry is mistaken for accepted feedback. - expect(commandRecords(test.session).map(record => record.kind)).toEqual(['error', 'error']) + expect(feedbackTexts(test.session)).toEqual([]) + const done = test.session.events.filter(event => event.type === 'command/done') + expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) + for (const event of test.session.events) { + if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false) + } }) it('records nothing when dispatch rejects an already-cancelled request', async () => { diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 9aa206f9ad..dbb175d304 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -92,11 +92,14 @@ describe('/feedback real Loader composition through cordis.yml', () => { text: 'Feedback text is required. Usage: /feedback ', }) - // The command records itself through the registry and does nothing else. + // The domain event owns the payload; generic command bookkeeping omits it. expect(owner.session.events.map(event => event.type)) - .toEqual(['command/run', 'command/done', 'command/run', 'command/done']) + .toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done']) const run = owner.session.events.find(event => event.type === 'command/run') - expect(run?.type === 'command/run' && run.data.args).toBe(' the diff view is unreadable') + expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) + const feedback = owner.session.events.find(event => event.type === 'feedback/record') + expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable') + expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) // Nothing reached the model. expect(owner.session.deriveMessages()).toEqual([]) diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index 6a27b54d3a..0a99f13f01 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../ui/commands" }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index c5a13bee7e..791f77b637 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: d3c2c14fe616e1c9b4e33b716570b084db6474cf -README.zh.md: 6d6878c4b0300a716ad16be60fd86bc79f1514ba +README.md: e3a98115d2d9f14fa0bb46e4d867f6b79cbf269d +README.zh.md: f8481cff12992e83af39498908c5ca2624a4f974 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index d3c2c14fe6..e3a98115d2 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -20,7 +20,7 @@ The TUI consumes the plugin-owned `/plan` command; other front doors may drive t ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. ## Configuration diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 6d6878c4b0..f8481cff12 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -20,7 +20,7 @@ TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan` 的 `command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量,host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉)。key 从 `src/types.ts` merge 进 `SessionProjectionMap`(host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan` 且带有已记录 `args` 的 `command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量,host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉)。key 从 `src/types.ts` merge 进 `SessionProjectionMap`(host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。 ## 配置 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index dc6b788825..9d584dc776 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -234,6 +234,7 @@ export class PlanModeService extends Service { init: () => ({ active: false, wanted: null }), apply: (state, event) => { if (event.type === 'command/run' && event.data.name === 'plan') { + if (event.data.args === undefined) return state const wanted = event.data.args.trim() !== 'off' return wanted === state.wanted ? state : { active: state.active, wanted } } diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 7c69417e58..cb662bc227 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -1,9 +1,9 @@ /** * The `plan` projection unit (session-projection RFC's complete example): a * double-event fold over the session log. `command/run` records named `plan` - * set the wanted target (`off` → false, anything else → true); `plan/mode` - * commits and clears it; `view` derives `{ active, pending }` where pending - * is true only while an outstanding selection differs from the logged state. + * with recorded input set the wanted target (`off` → false, anything else + * → true); `plan/mode` commits and clears it. `view` reports pending only + * while an outstanding selection differs from the logged state. * Pending is thereby a pure replay quantity — a cold fold answers it without * the service's in-memory intent. Composition without plan-mode has no `plan` * key; unloading the fiber removes it (HMR safety). @@ -88,6 +88,11 @@ describe('plan projection unit', () => { commandId: CommandId('other-1'), name: 'compact', args: '', source: { kind: 'user' }, }) expect(bench.values().plan).toEqual({ active: true, pending: false }) + // A command lifecycle with omitted input carries no plan selection. + bench.session.append('command/run', { + commandId: CommandId('plan-no-input'), name: 'plan', source: { kind: 'user' }, + }) + expect(bench.values().plan).toEqual({ active: true, pending: false }) runPlanCommand(bench.session, ' off', 1) expect(bench.values().plan).toEqual({ active: true, pending: true }) commitPlanMode(bench.session, false, 1) diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index 5c37ccbb16..339c11a248 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/commands/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/ui/commands/README.md -README.md: 4ad72cf9e232c8d41e525f42eecde5637032a391 -README.zh.md: bace8f6346ac737a838d802dfc5c6ffe52c56edd +README.md: 77397aadf8dd070d962d1a4f95dea2e4700a6c15 +README.zh.md: 8f02325271548b652b069433bcdb9c1c99de547e diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 4ad72cf9e2..77397aadf8 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -6,9 +6,9 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl ## Service contract -`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. -`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. `parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md index bace8f6346..8f02325271 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -6,9 +6,9 @@ ## 服务契约 -`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 +`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们。 +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器得到的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们。 `parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。 diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index b1a5121243..b6dea581eb 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -71,6 +71,12 @@ export interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor + /** + * Whether `command/run` records `rawInput`. Defaults to true. A command + * whose domain event owns the payload sets this false to avoid duplicating + * that payload in the session log. + */ + readonly recordInput?: boolean /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } @@ -127,9 +133,10 @@ declare module '@deepseek-ai/dsh-session' { * and `args` are `parseCommand`'s own split (name and verbatim rawInput, * separator whitespace included), so a consumer (a projection unit * folding its own command records, a rich command card) never re-parses - * a line. + * a line. `args` is absent when the definition sets `recordInput: false` + * because an authoritative domain event owns the input payload. */ - 'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource } + 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the @@ -239,6 +246,7 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { name: definition.name, description: definition.description, ...input === undefined ? {} : { input }, + ...definition.recordInput === undefined ? {} : { recordInput: definition.recordInput }, handler: definition.handler, }) const descriptor = Object.freeze({ @@ -357,7 +365,10 @@ export class CommandService extends Service { if (signal.aborted) throw abortError(signal) const commandId = this.mintCommandId() this.appendLifecycle(agent.session, 'command/run', { - commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' }, + commandId, + name: parsed.name, + ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, + source: { kind: 'user' }, }) const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) let result: CommandResult diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index f22e974d58..b85971f5d7 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -320,6 +320,25 @@ describe('CommandService', () => { ]) }) + it('omits raw input from command/run when an authoritative domain event owns it', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const seen = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register({ + name: 'private', + description: 'Record privately', + recordInput: false, + handler: seen, + }) + + await ctx.commands.execute(agent, '/private keep this once', new AbortController().signal) + + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) + const run = agent.session.events.find(event => event.type === 'command/run') + expect(run?.type).toBe('command/run') + expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) + }) + it('mints distinct monotonic commandIds across executions', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') From b8d51704f343d730fda933148e0c3a0f3176bb1f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:43:31 +0800 Subject: [PATCH 003/130] docs: refresh feedback module graph --- docs/module-graph.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 511e132360..61477ae104 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -618,6 +618,7 @@ flowchart TD pkg_client_ui_goal --> pkg_invariants pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants + pkg_command_feedback --> pkg_session pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -1092,7 +1093,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`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) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants) | +| [`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) | From 4fdfa89d51c5dd6d0e1ff160ad2461c8ae07dbb5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:16:15 +0800 Subject: [PATCH 004/130] feat(tools): render a Python SDK and dispatch Code Mode by runtime language Code Mode generated only a TypeScript SDK and rejected any runtime whose language was not "typescript". Add py-types.ts (jsonSchemaToPy / renderToolsSdkPy) and select the SDK-section renderer and the run_code schema flavor by ctx.codeRuntime.language through two parallel tables (SDK_RENDERERS, RUN_CODE_FLAVORS), read with Object.hasOwn and failing loud on a language with no renderer. The tool layer depends only on the code-runtime seam's language field, so it lands independently of the Python protocol and backend. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 6 + .../2026-07-31-code-mode-language-dispatch.md | 32 ++ ...26-07-31-code-mode-language-dispatch.zh.md | 32 ++ packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 10 +- packages/core/tools/README.zh.md | 6 +- packages/core/tools/src/code-mode.ts | 129 ++++- packages/core/tools/src/index.ts | 36 +- packages/core/tools/src/py-types.ts | 440 +++++++++++++++++ packages/core/tools/tests/code-mode.spec.ts | 48 +- packages/core/tools/tests/py-types.spec.ts | 462 ++++++++++++++++++ 11 files changed, 1176 insertions(+), 29 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md create mode 100644 packages/core/tools/src/py-types.ts create mode 100644 packages/core/tools/tests/py-types.spec.ts 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 new file mode 100644 index 0000000000..eb6fe2f9ae --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.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-07-31-code-mode-language-dispatch.md +2026-07-31-code-mode-language-dispatch.md: 6726842741988d71f5bce1885ebe7e8e7b3564d3 +2026-07-31-code-mode-language-dispatch.zh.md: 0bb381c410fbbb468d65076518861923021a4453 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 new file mode 100644 index 0000000000..6726842741 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -0,0 +1,32 @@ +# Agent Note: Code Mode language dispatch and the Python SDK renderer + +Status: implemented + +English | [中文](2026-07-31-code-mode-language-dispatch.zh.md) + +## Problem + +Code Mode generated one SDK flavor: TypeScript. `ToolRegistry` hard-coded `renderToolsSdk` for the `tools:sdk` section and `requireCodeRuntime` rejected any `ctx.codeRuntime.language !== 'typescript'`. Adding a CPython backend means a program's source language is no longer fixed: the same visible tool registry must project a Python SDK when a Python runtime is loaded, and the model-facing `run_code` schema strings ("Execute a Python program …") must match the SDK section's language so the model never sees a TypeScript instruction over a Python runtime. + +This is the tool-facing half of the multi-language Code Mode split; the [code-runtime seam](../../../../packages/code-runtime/code-runtime/README.md) already carries `CodeRuntime.language`. This note owns only how `dsh-tools` dispatches on that field. The backend that implements `language: 'python'` is owned by its own note, delivered separately. + +## Decision + +Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at prompt assembly, against two parallel tables in `dsh-tools`: + +- `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. + +`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. + +### 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. + +## Rejected alternatives + +- **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. +- **Importing the Python backend into `code-mode.ts` to detect it.** That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on `language` keeps the layer backend-agnostic and independently shippable. +- **A default renderer for an unknown language.** A silent fallback would emit a TypeScript SDK over, e.g., a Ruby runtime — the model would see instructions in the wrong language. Failing loud at assembly is the repository's misconfiguration stance. 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 new file mode 100644 index 0000000000..0bb381c410 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Code Mode 语言分发与 Python SDK 渲染器 + +Status: implemented + +[English](2026-07-31-code-mode-language-dispatch.md) | 中文 + +## 问题 + +Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sdk` 段硬编码了 `renderToolsSdk`,且 `requireCodeRuntime` 会拒绝任何 `ctx.codeRuntime.language !== 'typescript'`。引入 CPython 后端后,程序的源语言不再固定:同一个可见工具注册表在加载 Python 运行时时必须投射出 Python SDK,而面向模型的 `run_code` schema 字符串("Execute a Python program …")也必须与 SDK 段的语言一致,模型才不会在 Python 运行时下看到 TypeScript 指令。 + +这是多语言 Code Mode 拆分中面向工具的那一半;[代码运行时 seam](../../../../packages/code-runtime/code-runtime/README.md) 已经携带 `CodeRuntime.language`。本 Note 只负责 `dsh-tools` 如何在该字段上分发。实现 `language: 'python'` 的后端由它自己的 Note 负责,单独交付。 + +## 决策 + +语言选择就是对 `ctx.codeRuntime.language` 的查表,在 prompt 装配时惰性解析,查 `dsh-tools` 里两张平行的表: + +- `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`,也不动注册表结构。 + +`code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。 + +### Python SDK 渲染器 + +`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。 + +## 被否决的备选方案 + +- **在 `ToolRegistry` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真相来源,故注册表读取它而不复制它。 +- **把 Python 后端 import 进 `code-mode.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR 先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。 +- **为未知语言提供默认渲染器。** 静默回退会在比如 Ruby 运行时上发出 TypeScript SDK——模型会看到错误语言的指令。在装配处 fail loud 是本仓库对错误配置的立场。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index d26f891f91..e413309f32 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda -README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a +README.md: 0c6b5ec5bc213e8a568592f3aca7c79b52d73907 +README.zh.md: 80397e37c6e92053d825d4aa7d61e20455cd881a diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 15fc5839a3..0c6b5ec5bc 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 TypeScript `ctx.codeRuntime`, 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 via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md), Python via [`dsh-code-runtime-python`](../../code-runtime/code-runtime-python/README.md)); 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 @@ -114,9 +114,9 @@ Returning `undefined` selects generic fallback. Presenters depend only on their ### Code Mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, 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`. +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, at each assembly, `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. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly. +- **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). The TypeScript codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly. - **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 `:code:`, 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. @@ -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 `declare const tools` block. `both` exposes normal schemas and this Code Mode surface. +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`](../../code-runtime/code-runtime-python/README.md)) 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 is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. +- **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. - **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 8547ee4a79..80397e37c6 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` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `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 经 [`dsh-code-runtime-python`](../../code-runtime/code-runtime-python/README.md));没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 ### 公开 API @@ -114,7 +114,7 @@ ctx.tools.register(defineTool({ ### Code Mode -在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和确定性的 TypeScript SDK;只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的 `ToolArgsMap` 和 `ToolOutputMap` 条目,每个绑定都会解析为该工具的规范 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` 试用。 +在 `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):一个惰性提示词段,每次组装时都会重新生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。导出的代码生成器 `jsonSchemaToTs` 会处理统一 schema 的每种构造,并将不受支持的原始构造降级为 `unknown`,绝不会在提示词组装期间抛出。 - **分发桥接层**(`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 `:code:`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 @@ -145,7 +145,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及生成的精确 `declare const tools` 块。`both` 会同时公开普通 schema 与此 Code Mode 接口。 +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`](../../code-runtime/code-runtime-python/README.md))形状相同,只是换成 Python 语法(`await tools.name(args)`、异体名用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 244934de3f..38a0654ced 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -11,7 +11,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-session' -import { defineTool } from './schema.ts' +import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' import { TOOL_REGISTRY_SCHEDULER } from './index.ts' import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' @@ -56,6 +56,95 @@ export const RUN_CODE_NAME = 'run_code' /** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */ export const SDK_SECTION_ORDER = 150 +/** + * The language-specific `run_code` schema text: the tool `description` and its + * `code` parameter description, kept together so a language's two model-facing + * strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring + * `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the + * semantics the same language's SDK instructions promise, so the model never + * receives a TypeScript-shaped schema beside a Python SDK (or vice versa). + */ +interface RunCodeFlavor { + /** The tool `description` the model sees for this language. */ + readonly description: string + /** The `code` parameter's description for this language. */ + readonly codeDescription: string +} + +/** + * 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. + */ +const TYPESCRIPT_FLAVOR: RunCodeFlavor = { + 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.', + codeDescription: 'The program: the body of an async TypeScript function.', +} + +/** + * The Python flavor: the body of an async function, top-level `await` and + * `return`, answer via `print` and/or the returned value, matching + * {@link ./py-types.ts}'s SDK instructions. + */ +const PYTHON_FLAVOR: RunCodeFlavor = { + description: + 'Execute a Python program against the available tools. Write the BODY of an ' + + 'async function (top-level `await` and `return` work) and call tools as ' + + '`await tools.name(args)` per the declarations in the system prompt. Answer ' + + 'with `print(...)` and/or `return ` — only that comes back, so curate it.', + 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. */ +const RUN_CODE_FLAVORS: Record = { + typescript: TYPESCRIPT_FLAVOR, + python: PYTHON_FLAVOR, +} + +/** + * The `description` parameter's model-facing description: language-independent + * (the UI label contract is the same for every runtime), shared between the + * static spec and the language-aware `parameters` getter so the two emissions + * can never drift. + */ +const RUN_CODE_DESCRIPTION_PARAM_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".' + +/** + * 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. When no runtime is mounted the schema harvest + * degrades to {@link TYPESCRIPT_FLAVOR} (a doc-only path — an assembly always + * has one). A mounted runtime whose language has no flavor entry fails loud, + * keeping this table coupled to `SDK_RENDERERS`. + */ +function resolveFlavor(requireRuntime: () => CodeRuntime): RunCodeFlavor { + let runtime: CodeRuntime + try { + runtime = requireRuntime() + } catch { + // No runtime mounted: the only reader here is the static schema harvest + // (doc catalog), which never reaches a model — degrade to the TS default. + return TYPESCRIPT_FLAVOR + } + // Own-property read: a language like `toString`/`constructor` would otherwise + // resolve an inherited Object.prototype member as a flavor. + const flavor = RUN_CODE_FLAVORS[runtime.language] + /* v8 ignore next 3 -- requireRuntime rejects a language absent from SDK_RENDERERS, whose keys + mirror RUN_CODE_FLAVORS; the guard is defense-in-depth against the two tables drifting. */ + 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)}`) + } + return flavor +} + /** * Thrown by `run_code` when the program run itself failed — a program * exception, a budget expiry, an abort, or substrate death. Extends @@ -213,21 +302,21 @@ export interface RunCodeBridgeOptions { */ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition { const { requireRuntime, maxParallel, shapeDispatchLog } = options - return defineTool({ + const definition = defineTool({ name: RUN_CODE_NAME, - 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.', + // The description and `code` parameter description are placeholders here: + // the language-aware getters installed below replace both, resolving the + // loaded runtime's flavor at schema-emission time so the schema the MODEL + // sees matches the SDK section's language. Argument VALIDATION still keys + // off this static spec (defineTool closes over it), which is language- + // independent (one required string `code`). + description: TYPESCRIPT_FLAVOR.description, parameters: { - code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' }, + code: { type: 'string', required: true, description: TYPESCRIPT_FLAVOR.codeDescription }, description: { type: 'string', required: true, - 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".', + description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION, }, }, output: { @@ -569,4 +658,22 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge // title and reads durable result content without duplicating a large raw // result into the host view payload. }) + // Resolve the language flavor lazily, at the moment the registry projects the + // schema (`schemaOf` destructures `description`/`parameters`). The definition + // is minted once at registration, before a runtime is known; deferring here + // is the least invasive point that still emits the loaded runtime's language. + Object.defineProperty(definition, 'description', { + enumerable: true, + get: () => resolveFlavor(requireRuntime).description, + }) + Object.defineProperty(definition, 'parameters', { + enumerable: true, + // Recompile through the same spec→schema projection defineTool used, so + // the emitted shape can never drift from the validated one. + get: () => parameterSchemaSpecToJsonSchema({ + code: { type: 'string', required: true, description: resolveFlavor(requireRuntime).codeDescription }, + description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, + }) as unknown as Record, + }) + return definition } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index f30dce6cd0..17d1935cf1 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -24,6 +24,19 @@ import type { JsonSchemaNode } from './json-schema.ts' import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts' import { renderToolsSdk } from './ts-types.ts' import type { ToolSdkSchema } from './ts-types.ts' +import { renderToolsSdkPy } from './py-types.ts' + +/** + * Language → SDK-section renderer. The registry looks up the loaded + * `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. + */ +const SDK_RENDERERS: Record string> = { + typescript: renderToolsSdk, + python: renderToolsSdkPy, +} export { defineTool, @@ -65,6 +78,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session' export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' +export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts' // The render-intent vocabulary a tool declares via `presentCall`/`presentResult` @@ -762,10 +776,21 @@ export class ToolRegistry extends Service { ctx.systemPrompt.section({ name: 'tools:sdk', order: SDK_SECTION_ORDER, - // Regenerate from the calling scope's visible tools in stable order. + // Regenerate from the calling scope's visible tools in stable order, + // picking the renderer that matches the loaded runtime's language. + // `requireCodeRuntime` already validated the language is in the + // table, so the fallback here is defense-in-depth against a caller + // that bypassed the guard (impossible under normal composition). text: (context) => { - this.requireCodeRuntime() - return renderToolsSdk(this.sdkSchemas(context.scope)) + const runtime = this.requireCodeRuntime() + // Own-property read: a language like `toString`/`constructor` would + // otherwise resolve an inherited Object.prototype member as a renderer. + 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}"`) + } + return render(this.sdkSchemas(context.scope)) }, }) } @@ -804,8 +829,9 @@ export class ToolRegistry extends Service { if (!runtime) { throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`) } - if (runtime.language !== 'typescript') { - throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`) + if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) { + const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ') + throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) } return runtime } diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts new file mode 100644 index 0000000000..80c1ee7dc5 --- /dev/null +++ b/packages/core/tools/src/py-types.ts @@ -0,0 +1,440 @@ +/** + * Code Mode codegen — Python flavor. The pure projection from registered tool schemas to the + * Python SDK text the model programs against under `runtime.language === 'python'`. Sibling of + * {@link ./ts-types.ts | ts-types.ts}; the two files are two projections of the same registry + * 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. + * @module @deepseek-ai/dsh-tools/src/py-types + */ + +import { assertSupportedJsonSchema } from './json-schema.ts' +import type { 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_]*$/ + +/** + * Python 3.x soft-keyword-inclusive reserved set. A tool named ``class`` or + * ``lambda`` is legal on the wire but not as an attribute (``tools.class`` + * would be a SyntaxError in the model program), so we render it under + * subscript access — the model still reaches every tool without collisions. + * Underscore-leading names (``_x``, ``__class__``) are also subscript-only: + * dunders resolve on ``object`` before the proxy's fallback hook, and the + * subscript path is the one guaranteed bridge route for them. + * The same set rejects an argument field whose name would be an illegal + * class-syntax `TypedDict` attribute, degrading that object to + * ``dict[str, Any]``. + */ +const RESERVED = new Set([ + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', + 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', + 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', + 'return', 'try', 'while', 'with', 'yield', 'match', 'case', + // Not a keyword, but CPython refuses to ASSIGN it at compile time + // (`SyntaxError: cannot assign to __debug__`), which is what a TypedDict + // field, a parameter name, and a keyword argument all are. + '__debug__', +]) + +/** `typing` symbols this module may emit, in the deterministic import order. */ +const TYPING_ORDER = ['Any', 'Literal', 'NotRequired', 'Protocol', 'TypedDict'] as const + +/** `indent`-deep line prefix (four spaces per level to match PEP 8 output). */ +function pad(indent: number): string { + return ' '.repeat(indent) +} + +/** + * 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. + */ +interface RenderState { + readonly classes: string[] + readonly usedClassNames: Set + readonly typing: Set +} + +/** + * Control characters that survive the whitespace collapse in {@link describe} + * and have no printable form. 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. + */ +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. + * + * Control characters left over after the whitespace collapse are rendered as + * their `\xNN` escapes (see {@link UNPRINTABLE}); 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. + */ +function describe(schema: object): string | undefined { + const description = (schema as Record).description + if (typeof description !== 'string' || description.length === 0) return undefined + return description + .replace(/\s+/g, ' ') + .replace(UNPRINTABLE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`) + .trim() +} + +/** + * One-line docstring for a tool `description`, or no lines when there is none. + * Backslashes are doubled first, every quote is escaped, and a trailing + * backslash cannot survive: a description ending in `"` or an odd backslash + * would otherwise merge with (or escape) the closing triple quote and make + * the generated block — Code Mode's only SDK — syntactically invalid Python. + */ +function docLines(description: unknown, indent: number): string[] { + const collapsed = describe({ description }) + if (collapsed === undefined) return [] + const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', '\\"') + return [`${pad(indent)}"""${escaped}"""`] +} + +/** CamelCase a name into a Python type identifier (non-identifier chars split words; a non-letter head is prefixed). */ +function camelCase(raw: string): string { + const joined = raw + .split(/[^A-Za-z0-9]+/) + .filter(part => part.length > 0) + .map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join('') + return /^[A-Za-z]/.test(joined) ? joined : `Tool${joined}` +} + +/** Reserve a unique class name, suffixing a counter on collision after CamelCase sanitization. */ +function allocateClassName(base: string, state: RenderState): string { + let name = base + for (let n = 2; state.usedClassNames.has(name); n++) name = `${base}${n}` + state.usedClassNames.add(name) + return name +} + +/** + * 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 + * 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. + */ +function pyScalar(value: JsonSchemaScalar): string { + if (value === true) return 'True' + if (value === false) return 'False' + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) { + return BigInt(value).toString() + } + return String(value) +} + +/** Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to the broad type. */ +function renderConstrainedScalar(node: Record, broad: string, state: RenderState): string { + if (Object.hasOwn(node, 'const')) { + state.typing.add('Literal') + return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]` + } + if (Object.hasOwn(node, 'enum')) { + state.typing.add('Literal') + return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]` + } + return broad +} + +/** + * Map one JSON-Schema node to a Python type expression, threading `state` to + * collect the `TypedDict` declarations and `typing` symbols a full render + * 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. + */ +function renderType(schema: unknown, className: string, state: RenderState): string { + interface Frame { + schema: unknown + className: string + phase: 'start' | 'children' + kind?: 'oneOf' | 'array' | 'typeddict' + node?: Record + children: { schema: unknown; className: string }[] + childIndex: number + 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)] + 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. */ + 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') { + // `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 + } + // 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 + } + + 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 + 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]') + 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': { + const properties = node.properties + if (typeof properties !== 'object' || properties === null) { + state.typing.add('Any') + finish('dict[str, Any]') + break + } + const entries = Object.entries(properties as Record) + // 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 + } + /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */ + default: { + state.typing.add('Any') + finish('Any') + } + } + } + /* v8 ignore next -- every root frame produces one expression. */ + return result ?? 'Any' +} + +/** + * Map one JSON-Schema node to a context-free Python type expression from the + * `typing` module. Handles every unified schema construct — `object` (degraded + * 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). + * @returns the Python type text. + */ +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() }) +} + +/** 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: + +- 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. +- 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 \`; 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. + +The available tools:` + +/** + * Render the full `tools:sdk` prompt section under `runtime.language === + * 'python'`: the Python-flavored usage instructions plus one named `TypedDict` + * per tool argument or output object (and per nested object) and one awaitable + * method per visible tool on a `Tools` protocol — typed args in, the tool's + * canonical output value out — with a `tools: Tools` singleton the model calls + * into. The `typing` import line lists exactly the symbols the render used. + * 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. + * @param schemas - the tool schemas plus canonical output schemas to declare + * (the caller excludes `run_code` itself). + * @returns the complete section text. + */ +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 inlineMembers: string[] = [] + const subscriptMembers: 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('_')) { + inlineMembers.push(...docLines(schema.description, 1)) + inlineMembers.push(`${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`) + } 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}`) + const description = describe(schema) + if (description !== undefined) subscriptMembers.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] + 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` : '' + const errorDeclaration = 'class ToolCallError(Exception):\n toolName: str' + const declaration = `from typing import ${imports.join(', ')}\n\n${errorDeclaration}\n\n${classBlock}class Tools(Protocol):\n${body}\n\ntools: Tools` + return `${SDK_INSTRUCTIONS}\n\n\`\`\`python\n${declaration}\n\`\`\`` +} diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index b06e29866d..ca488738ce 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -335,9 +335,51 @@ describe('mode-aware wire contribution', () => { await expect(systemPrompt.assemble()).rejects.toThrow(/requires a code runtime/) }) - it("rejects every assembly when the runtime's language is not typescript", async () => { - const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } }) - await expect(systemPrompt.assemble()).rejects.toThrow(/language is "python"/) + it('rejects every assembly when the runtime language has no registered SDK renderer', async () => { + const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } }) + await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/) + }) + + it('assembles under a python runtime by picking the Python SDK renderer', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + const sdk = assembly.sections.find(section => section.name === 'tools:sdk') + expect(sdk?.text).toContain('class Tools(Protocol):') + expect(sdk?.text).toContain('async def echo(self, args:') + expect(sdk?.text).toContain('top-level `await`') + }) + + 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) + const assembly = await systemPrompt.assemble() + const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME) + expect(runCodeSchema?.description).toContain('Execute a TypeScript program') + expect(runCodeSchema?.description).toContain('BODY of an') + const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code + expect(codeParam.description).toBe('The program: the body of an async TypeScript function.') + }) + + it('emits a Python-flavored run_code schema under a python runtime (matches the SDK language)', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } }) + registerEcho(ctx) + const assembly = await systemPrompt.assemble() + const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME) + expect(runCodeSchema?.description).toContain('Execute a Python program') + expect(runCodeSchema?.description).toContain('`return `') + expect(runCodeSchema?.description).not.toContain('TypeScript') + const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code + expect(codeParam.description).toBe('The program: the body of an async Python function.') + }) + + it('fails loud when the runtime language has no run_code schema flavor', async () => { + // A language with an SDK renderer registered but (hypothetically) no schema + // flavor would fail here; a language with neither fails earlier at + // requireCodeRuntime. Both guards keep the two tables coupled. + const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } }) + registerEcho(ctx) + await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/) }) it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts new file mode 100644 index 0000000000..93b9990ac3 --- /dev/null +++ b/packages/core/tools/tests/py-types.spec.ts @@ -0,0 +1,462 @@ +import { describe, expect, it } from 'vitest' +import { jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tools/src/py-types.ts' +import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' +import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' + +describe('jsonSchemaToPy', () => { + it('maps the defineTool DSL subset', () => { + const cases: [unknown, string][] = [ + [{ type: 'string' }, 'str'], + [{ type: 'number' }, 'float'], + [{ type: 'boolean' }, 'bool'], + [{ type: 'string', enum: ['a', 'b'] }, 'Literal["a", "b"]'], + [{ type: 'array', items: { type: 'number' } }, 'list[float]'], + [{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, 'list[Literal["x", "y"]]'], + [{ type: 'array' }, 'list[Any]'], + [{ type: 'object' }, 'dict[str, Any]'], + [{ type: 'object', properties: {} }, 'dict[str, Any]'], + [{ type: 'object', properties: { x: { type: 'string' } } }, 'dict[str, Any]'], + ] + for (const [schema, expected] of cases) { + expect(jsonSchemaToPy(schema), JSON.stringify(schema)).toBe(expected) + } + }) + + it('is total: unsupported or hostile constructs degrade to Any, never throw', () => { + const cases: unknown[] = [ + undefined, + null, + 42, + 'string-schema', + {}, + { oneOf: 7 }, + { $ref: '#/defs/x' }, + { type: 'object', properties: 7 }, + { type: 'string', enum: [1, 2] }, + { type: 'string', enum: [] }, + ] + for (const schema of cases) { + expect(() => jsonSchemaToPy(schema), JSON.stringify(schema)).not.toThrow() + } + expect(jsonSchemaToPy({ type: 'integer' })).toBe('int') + expect(jsonSchemaToPy({ type: 'string', const: 'fixed' })).toBe('Literal["fixed"]') + expect(jsonSchemaToPy({ type: 'boolean', const: true })).toBe('Literal[True]') + expect(jsonSchemaToPy({ type: 'number', const: 1.5 })).toBe('Literal[1.5]') + expect(jsonSchemaToPy({ type: 'boolean', enum: [false] })).toBe('Literal[False]') + expect(jsonSchemaToPy({ type: 'null' })).toBe('None') + expect(jsonSchemaToPy({ oneOf: [{ type: 'string' }, { type: 'null' }] })).toBe('str | None') + expect(jsonSchemaToPy({ oneOf: [] })).toBe('Any') + expect(jsonSchemaToPy({ type: 'object', properties: 7 })).toBe('Any') + expect(jsonSchemaToPy({ type: 'string', enum: [1, 2] })).toBe('Any') + expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('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 + // ...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. + expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 60 })).toBe('Literal[1152921504606846976]') + expect(jsonSchemaToPy({ type: 'integer', enum: [2 ** 60, -(2 ** 60)] })) + .toBe('Literal[1152921504606846976, -1152921504606846976]') + // `String(1e21)` prints `1e+21`, not a Python integer literal at all. The + // rule keys off the VALUE, not the declared type, so a `number` const that + // happens to be an integral double is spelled the same exact way (both + // spellings denote the same double, and only the digits also denote the + // same Python integer). + expect(jsonSchemaToPy({ type: 'integer', const: 1e21 })).toBe('Literal[1000000000000000000000]') + expect(jsonSchemaToPy({ type: 'number', const: 1e21 })).toBe('Literal[1000000000000000000000]') + // Within the safe range, and for non-integral numbers, the plain spelling + // is already exact and stays unchanged. + expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 53 - 1 })).toBe('Literal[9007199254740991]') + expect(jsonSchemaToPy({ type: 'number', const: 1e-7 })).toBe('Literal[1e-7]') + }) +}) + +describe('renderToolsSdkPy', () => { + const bash: ToolSdkSchema = { + name: 'bash', + description: 'Run a shell command.', + parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, + output: { type: 'string' }, + } + const exotic: ToolSdkSchema = { + name: 'my-mcp.tool', + description: 'Exotic name.', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, + output: { type: 'string' }, + } + const reserved: ToolSdkSchema = { + name: 'class', + description: 'Uses a reserved Python word.', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, + output: { type: 'string' }, + } + + it('declares identifier tools as async methods and lists exotic/reserved names as subscript comments', () => { + const text = renderToolsSdkPy([exotic, bash, reserved]) + 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: ...') + // 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') + // Fixed instruction lines the model relies on. + expect(text).toContain('top-level `await`') + expect(text).toContain('ToolCallError') + expect(text).toContain('class ToolCallError(Exception):') + expect(text).toContain('MAY overlap under `asyncio.gather`') + expect(text).toContain('lossless JSON') + expect(text).toContain('```python') + expect(text).toContain('tools: Tools') + }) + + it('renders required as plain fields and optional as NotRequired, with per-field description comments', () => { + const tool: ToolSdkSchema = { + name: 'search', + description: 'Search for text.', + parameters: parameterSchemaSpecToJsonSchema({ + query: { type: 'string', required: true, description: 'What to search for.' }, + limit: { type: 'number', description: 'Max results.' }, + }) as unknown as Record, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('class SearchArgs(TypedDict):') + expect(text).toContain(' # What to search for.') + 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: ...') + // 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') + }) + + it('prefixes Tool when a name CamelCases to a non-letter head, and degrades a malformed schema to Any', () => { + const tool: ToolSdkSchema = { + name: '1st-tool', // subscript path; CamelCases to "1stTool" → prefixed "Tool1stTool" + description: 'Hostile-shape probe.', + // Malformed node: the unified schema validator rejects it whole, so the + // args position degrades to Any (registration would refuse this schema; + // the renderer just must not throw on it). + parameters: { type: 'object', properties: { field: { type: 'string', description: 42 } } }, + output: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('# tools["1st-tool"](args: Any) -> Tool1stToolOutput') + expect(text).toContain('class Tool1stToolOutput(TypedDict):') + expect(text).toContain(' ok: bool') + }) + + it('treats every field as optional when the object carries no required array', () => { + const tool: ToolSdkSchema = { + name: 'all_optional', + description: 'No required array.', + parameters: { type: 'object', properties: { flag: { type: 'boolean' } } }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain(' flag: NotRequired[bool]') + }) + + it('renders an enum inside an object property as a Literal field', () => { + const tool: ToolSdkSchema = { + name: 'mode_tool', + description: 'Pick a mode.', + parameters: parameterSchemaSpecToJsonSchema({ + mode: { type: 'string', required: true, enum: ['fast', 'slow'] }, + }) as unknown as Record, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('class ModeToolArgs(TypedDict):') + expect(text).toContain(' mode: Literal["fast", "slow"]') + expect(text).toContain('from typing import Literal, Protocol, TypedDict') + }) + + it('renders one level of nested object as its own named TypedDict declared before the parent', () => { + const tool: ToolSdkSchema = { + name: 'workflow', + description: 'Run a workflow.', + parameters: parameterSchemaSpecToJsonSchema({ + meta: { + type: 'object', + required: true, + additionalProperties: false, + description: 'Identity block.', + properties: { + name: { type: 'string', required: true, description: 'Short name.' }, + phases: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { title: { type: 'string', required: true, description: 'Phase title.' } }, + }, + }, + }, + }, + }) as unknown as Record, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + // Nested class for the `meta` object, and a further nested class for the + // array item object, each named after its field path. + expect(text).toContain('class WorkflowArgsMeta(TypedDict):') + expect(text).toContain('class WorkflowArgsMetaPhases(TypedDict):') + expect(text).toContain(' meta: WorkflowArgsMeta') + expect(text).toContain(' phases: NotRequired[list[WorkflowArgsMetaPhases]]') + // Dependency-before-dependent: the item class precedes its container, + // which precedes the top-level args class, which precedes the protocol. + expect(text.indexOf('class WorkflowArgsMetaPhases')).toBeLessThan(text.indexOf('class WorkflowArgsMeta(TypedDict):')) + expect(text.indexOf('class WorkflowArgsMeta(TypedDict):')).toBeLessThan(text.indexOf('class WorkflowArgs(TypedDict):')) + expect(text.indexOf('class WorkflowArgs(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):')) + }) + + it('suffixes a counter when two tools CamelCase to the same class base', () => { + const a: ToolSdkSchema = { + name: 'my-tool', + description: 'Dash form.', + parameters: parameterSchemaSpecToJsonSchema({ x: { type: 'string', required: true } }) as unknown as Record, + output: { type: 'string' }, + } + const b: ToolSdkSchema = { + name: 'my.tool', + description: 'Dot form.', + parameters: parameterSchemaSpecToJsonSchema({ y: { type: 'string', required: true } }) as unknown as Record, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([a, b]) + // Both sanitize to `MyToolArgs`; the second collides and gets a suffix. + expect(text).toContain('class MyToolArgs(TypedDict):') + expect(text).toContain('class MyToolArgs2(TypedDict):') + }) + + it('references the named TypedDict from a reserved/subscript tool too', () => { + const tool: ToolSdkSchema = { + name: 'class', + description: 'Reserved word tool.', + parameters: parameterSchemaSpecToJsonSchema({ value: { type: 'string', required: true } }) as unknown as Record, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('class ClassArgs(TypedDict):') + expect(text).toContain('# tools["class"](args: ClassArgs) -> str') + }) + + it('degrades an object to dict[str, Any] when a field name is not a legal Python attribute', () => { + const tool: ToolSdkSchema = { + name: 'weird_fields', + description: 'Has an illegal field name.', + parameters: { type: 'object', properties: { 'a-b': { type: 'string' } } }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('async def weird_fields(self, args: dict[str, Any]) -> str: ...') + expect(text).not.toContain('WeirdFieldsArgs') + }) + + it('renders docstrings for descriptions and orders emissions lexicographically', () => { + const text = renderToolsSdkPy([bash, exotic]) + expect(text).toContain('"""Run a shell command."""') + // 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). + expect(text.indexOf('async def bash')).toBeLessThan(text.indexOf('# tools["my-mcp.tool"]')) + }) + + 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])) + }) + + it('renders a pass body and a minimal import for an empty tool set', () => { + const text = renderToolsSdkPy([]) + expect(text).toContain('class Tools(Protocol):') + expect(text).toContain(' pass') + // Nothing but the protocol is used, so the import line is just Protocol. + expect(text).toContain('from typing import Protocol') + }) + + it('omits the docstring/comment when a schema has no description', () => { + const undescribedIdentifier: ToolSdkSchema = { + name: 'plain', + description: '', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, + output: { type: 'string' }, + } + const undescribedExotic: ToolSdkSchema = { + name: 'weird-name', + description: '', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([undescribedIdentifier, undescribedExotic]) + // Identifier method appears without a docstring line above it. + expect(text).toContain('async def plain(self, args: dict[str, Any]) -> str: ...') + expect(text).not.toContain('"""') + // Subscript entry appears without the "# ..." description follow-up. + expect(text).toContain('# tools["weird-name"]') + expect(text.split('\n').every(line => !line.startsWith(' # '))).toBe(true) + }) + + it('marks an open object TypedDict and declares a closed empty object', () => { + const t: ToolSdkSchema = { + name: 'openness', + description: '', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + open: { type: 'object', additionalProperties: true, properties: { x: { type: 'string' } }, required: ['x'] }, + closedEmpty: { type: 'object', additionalProperties: false, properties: {} }, + }, + required: ['open', 'closedEmpty'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([t]) + // The open nested object carries the in-band openness note... + expect(text).toContain('class OpennessArgsOpen(TypedDict):') + expect(text).toMatch(/class OpennessArgsOpen\(TypedDict\):\n x: str\n # Additional keys beyond those declared are allowed\./) + // ...the closed root does not... + expect(text).toMatch(/class OpennessArgs\(TypedDict\):\n open: OpennessArgsOpen\n closedEmpty: OpennessArgsClosedEmpty\n\n/) + // ...and a closed EMPTY object declares an empty TypedDict rather than + // degrading to dict[str, Any] (which would falsely accept any keys). + expect(text).toMatch(/class OpennessArgsClosedEmpty\(TypedDict\):\n pass/) + expect(text).toContain('closedEmpty: OpennessArgsClosedEmpty') + }) + + it('renders a deeply nested array schema without exhausting the call stack', () => { + // The registry supports depth-unbounded schemas; the renderer must not + // reintroduce a recursion limit during prompt assembly. + let deep: Record = { 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) + }) + + it('emits pass for a subscript-only tool set (comments are not statements)', () => { + const t: ToolSdkSchema = { + name: 'my-exotic.tool', + description: '', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([t]) + // The class body must contain a statement before the subscript comments. + expect(text).toMatch(/class Tools\(Protocol\):\n pass\n # tools\["my-exotic\.tool"\]/) + }) + + it('degrades an object whose field would be name-mangled (__token) to dict[str, Any]', () => { + // Class-syntax TypedDict mangles a leading-double-underscore non-dunder + // annotation to _ClassName__token — a different JSON key than the schema. + const t: ToolSdkSchema = { + name: 'mangler', + description: '', + parameters: { + type: 'object', + additionalProperties: false, + properties: { __token: { type: 'string' } }, + required: ['__token'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([t]) + expect(text).toContain('async def mangler(self, args: dict[str, Any]) -> str: ...') + expect(text).not.toContain('__token:') + // Dunder-form fields (__meta__) are NOT mangled and stay expressible. + const dunder: ToolSdkSchema = { + name: 'dunder', + description: '', + parameters: { + type: 'object', + additionalProperties: false, + properties: { __meta__: { type: 'string' } }, + required: ['__meta__'], + }, + output: { type: 'string' }, + } + expect(renderToolsSdkPy([dunder])).toContain('__meta__: str') + }) + + it('degrades an object with a __debug__ field, which CPython refuses to assign', () => { + // `__debug__` is a legal identifier and dunder-form, so it clears both the + // identifier rule and the name-mangling rule, but CPython rejects the + // annotation at COMPILE time (`SyntaxError: cannot assign to __debug__`) — + // and this block is Code Mode's only SDK, so it must always parse. + const t: ToolSdkSchema = { + name: 'debugger', + description: '', + parameters: { + type: 'object', + additionalProperties: false, + properties: { __debug__: { type: 'string' } }, + required: ['__debug__'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([t]) + expect(text).toContain('async def debugger(self, args: dict[str, Any]) -> str: ...') + expect(text).not.toContain('__debug__') + }) + + 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 + // Mode's only SDK, so it must always parse. + const make = (description: string): ToolSdkSchema => ({ + name: 'weird', + description, + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, + output: { type: 'string' }, + }) + const trailingQuote = renderToolsSdkPy([make('ends in a quote"')]) + expect(trailingQuote).toContain(String.raw`"""ends in a quote\""""`) + const trailingBackslash = renderToolsSdkPy([make('ends in a backslash\\')]) + expect(trailingBackslash).toContain(String.raw`"""ends in a backslash\\"""`) + const tripleQuote = renderToolsSdkPy([make('contains """ triple quote')]) + expect(tripleQuote).toContain(String.raw`"""contains \"\"\" triple quote"""`) + }) + + it('escapes unprintable control characters, which CPython refuses inside source at all', () => { + // `compile()` raises `SyntaxError: source code string cannot contain null + // bytes` for a NUL ANYWHERE in the source text, including inside a string + // literal or a comment, so a NUL that survives normalization into a + // docstring or a `#` field comment stops this block — Code Mode's only SDK — + // 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, + output: { type: 'string' }, + }) + const nul = renderToolsSdkPy([make('before\u0000after')]) + // Both emission sites: the class 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. + expect(nul).not.toContain('\u0000') + expect(nul).toContain(String.raw`"""before\\x00after"""`) + expect(nul).toContain(String.raw`# before\x00after`) + // 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')]) + expect(others).toContain(String.raw`bell\x07esc\x1bdel\x7f`) + expect(renderToolsSdkPy([make('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""') + }) +}) From 15e43ee88baf92b632edee7557b699729d7c5722 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:22:25 +0800 Subject: [PATCH 005/130] fix(tools): satisfy doc gates for the Python SDK renderer - Delink dsh-code-runtime-python README references (that package ships in a later PR of the split; keep the package name unlinked meanwhile). - Add the mandatory `## Alternatives considered` and `## Consequences` sections to the language-dispatch Agent Note. - Regenerate config/cordis catalogs and the event graph for the shifted index.ts source lines; re-record the README and note i18n pairings. --- .../2026-07-31-code-mode-language-dispatch.i18n.yaml | 4 ++-- .../2026-07-31-code-mode-language-dispatch.md | 6 +++++- .../2026-07-31-code-mode-language-dispatch.zh.md | 6 +++++- 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/README.i18n.yaml | 4 ++-- packages/core/tools/README.md | 4 ++-- packages/core/tools/README.zh.md | 4 ++-- 10 files changed, 32 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 eb6fe2f9ae..7160013fb7 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: 6726842741988d71f5bce1885ebe7e8e7b3564d3 -2026-07-31-code-mode-language-dispatch.zh.md: 0bb381c410fbbb468d65076518861923021a4453 +2026-07-31-code-mode-language-dispatch.md: 55af3c7b71c7fc55d5140edb86494f2ca83d41c4 +2026-07-31-code-mode-language-dispatch.zh.md: 3a2eb78ec48f4479e2eb82a6a1e4f351a36c8bd3 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 6726842741..55af3c7b71 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,8 +25,12 @@ 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. -## Rejected alternatives +## 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. - **Importing the Python backend into `code-mode.ts` to detect it.** That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on `language` keeps the layer backend-agnostic and independently shippable. - **A default renderer for an unknown language.** A silent fallback would emit a TypeScript SDK over, e.g., a Ruby runtime — the model would see instructions in the wrong language. Failing loud at assembly is the repository's misconfiguration stance. + +## 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. 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 0bb381c410..3a2eb78ec4 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,8 +25,12 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd `py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。 -## 被否决的备选方案 +## Alternatives considered - **在 `ToolRegistry` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真相来源,故注册表读取它而不复制它。 - **把 Python 后端 import 进 `code-mode.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR 先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。 - **为未知语言提供默认渲染器。** 静默回退会在比如 Ruby 运行时上发出 TypeScript SDK——模型会看到错误语言的指令。在装配处 fail loud 是本仓库对错误配置的立场。 + +## Consequences + +新增一门后端语言就是一条表项加它的渲染器,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测;代价是在该后端发布前无法真正端到端跑一个 `python` 运行时,故本 PR 的覆盖是 unit 级(渲染器输出与分发/拒绝路径),而非真实的 Python 运行。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 12bde08080..636573056c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1994,7 +1994,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:589`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:603`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 497e0d220d..eb41cedb47 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -938,7 +938,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:167`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -962,7 +962,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:149`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -984,7 +984,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:124`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -1007,7 +1007,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:136`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:150`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -1028,7 +1028,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:113`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:127`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -1047,7 +1047,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:157`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:171`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 478a147361..6d613454ee 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2323,7 +2323,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:725`](../../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 cce2258651..1210e0c7b1 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:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../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:124`](../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:136`](../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:113`](../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:157`](../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: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) | | `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/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index e413309f32..c3e4554f70 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: 0c6b5ec5bc213e8a568592f3aca7c79b52d73907 -README.zh.md: 80397e37c6e92053d825d4aa7d61e20455cd881a +README.md: ac08bc72c6f9c6de6a0aef5cb866cd0488b1dd5c +README.zh.md: e040d85b3eb75cf23f7fb2fa8d16d0685ff2f4ac diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 0c6b5ec5bc..ac08bc72c6 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`](../../code-runtime/code-runtime-python/README.md)); 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 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. ### 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`](../../code-runtime/code-runtime-python/README.md)) 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 (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 SDK instructions diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 80397e37c6..e040d85b3e 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`](../../code-runtime/code-runtime-python/README.md));没有渲染器的运行时语言会让提示词组装响亮失败;如果 `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 经 `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`](../../code-runtime/code-runtime-python/README.md))形状相同,只是换成 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 风格(经 `dsh-code-runtime-python`)形状相同,只是换成 Python 语法(`await tools.name(args)`、异体名用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 From 85a831259c4f8b136c90d54081ba1f11163dfd77 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:31:41 +0800 Subject: [PATCH 006/130] docs(tools): clarify language-dispatch comments and fix stale zh limitation Address ds-review-bot suggestions on the Python SDK renderer PR: - resolveFlavor: widen the JSDoc and catch comment to name the invalid-language path the doc-catalog harvest also degrades through. - wireSchemas: note the requireCodeRuntime() call is an intentional single gate, redundant with the per-getter resolveFlavor path. - README: link the service-wide-language limitation to its Agent Note, and correct the Chinese bullet that still claimed TypeScript-only. --- 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 | 16 ++++++++++------ packages/core/tools/src/index.ts | 4 ++++ 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index c3e4554f70..9ff6974195 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: ac08bc72c6f9c6de6a0aef5cb866cd0488b1dd5c -README.zh.md: e040d85b3eb75cf23f7fb2fa8d16d0685ff2f4ac +README.md: 1b19a080759fa8b21f0c1058d049b2c3f6cf64fe +README.zh.md: 07fc85e2fc51c0b528eb37f6e7599d72144db8fb diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index ac08bc72c6..1b19a08075 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` 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. +- **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 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 e040d85b3e..07fc85e2fc 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 只支持 TypeScript,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native,而另一个仅使用 Code。 +- **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 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 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 38a0654ced..22a660a8ef 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -120,18 +120,22 @@ 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. When no runtime is mounted the schema harvest - * degrades to {@link TYPESCRIPT_FLAVOR} (a doc-only path — an assembly always - * has one). A mounted runtime whose language has no flavor entry fails loud, - * keeping this table coupled to `SDK_RENDERERS`. + * the SDK section's language. When no runtime is mounted, or one whose language + * has no renderer is, the schema harvest degrades to {@link TYPESCRIPT_FLAVOR} + * (a doc-only path — a real assembly always mounts a valid runtime, and + * `requireCodeRuntime` rejects an invalid language there first). A mounted + * runtime whose language passes that guard but is absent from this table fails + * loud, keeping this table coupled to `SDK_RENDERERS`. */ function resolveFlavor(requireRuntime: () => CodeRuntime): RunCodeFlavor { let runtime: CodeRuntime try { runtime = requireRuntime() } catch { - // No runtime mounted: the only reader here is the static schema harvest - // (doc catalog), which never reaches a model — degrade to the TS default. + // Reached only by the static schema harvest (doc catalog), which never + // feeds a model: either no runtime is mounted, or requireRuntime rejected + // a language with no renderer. Both degrade to the TS default here; a real + // assembly hits requireCodeRuntime's loud rejection before this runs. return TYPESCRIPT_FLAVOR } // Own-property read: a language like `toString`/`constructor` would otherwise diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 17d1935cf1..5f89fa23c6 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -806,6 +806,10 @@ export class ToolRegistry extends Service { if (this.mode === 'native') { return { schemas, knownNames: [...view.knownNames] } } + // Redundant with the per-getter resolveFlavor path (schemaOf's run_code + // description/parameters getters call requireCodeRuntime again): kept as a + // single explicit gate so a mode collapse rejects here regardless of + // whether any getter runs. The call is idempotent (ctx.get + Object.hasOwn). this.requireCodeRuntime() if (this.mode === 'code') { return { From 683c92cb2e0b8aaaf7636678a6e261423c7a0303 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:55:07 +0800 Subject: [PATCH 007/130] fix(tools): correct Python SDK fidelity and language-dispatch contract Address ds-review-bot v5/v6 review on the Python SDK renderer: - resolveFlavor now takes a peekRuntime() reader: undefined (no runtime, the doc-catalog harvest) degrades to the TS flavor, but a mounted runtime whose language is absent from RUN_CODE_FLAVORS fails loud. This removes the try/catch that silently swallowed the invalid-language path and drops the /* v8 ignore */ that hid the flavor guard from coverage; wireSchemas validates the runtime before projecting schemas so the renderer-table rejection stays the canonical assembly error. - py-types RESERVED drops the soft keywords match/case: they are legal as TypedDict fields and methods, so keeping them needlessly degraded common search/regex arg objects to dict[str, Any]. - py-types treats an object with omitted properties as {} like the unified validator and TS renderer do, so a closed empty object declares an empty TypedDict instead of a permissive dict[str, Any]. - README: symmetric jsonSchemaToPy->Any note; a stale zh SDK bullet and limitation corrected; link the service-wide-language limitation to its Agent Note. --- 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 | 40 ++++++++-------- packages/core/tools/src/index.ts | 19 ++++---- packages/core/tools/src/py-types.ts | 39 ++++++++------- packages/core/tools/tests/code-mode.spec.ts | 28 ++++++++--- packages/core/tools/tests/py-types.spec.ts | 53 +++++++++++++++++++++ 8 files changed, 130 insertions(+), 57 deletions(-) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 9ff6974195..11767a300e 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: 1b19a080759fa8b21f0c1058d049b2c3f6cf64fe -README.zh.md: 07fc85e2fc51c0b528eb37f6e7599d72144db8fb +README.md: ba8310b0b378d27d228a6e551e4b917c33e78fe5 +README.zh.md: 56cc1637f673559fe5f3c7cdf36bec80b8906eaa diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 1b19a08075..ba8310b0b3 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). The TypeScript codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly. +- **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 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 `:code:`, 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 07fc85e2fc..56cc1637f6 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):一个惰性提示词段,每次组装时都会重新生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。导出的代码生成器 `jsonSchemaToTs` 会处理统一 schema 的每种构造,并将不受支持的原始构造降级为 `unknown`,绝不会在提示词组装期间抛出。 +- **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]`)。 - **分发桥接层**(`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 `:code:`,按提交顺序编号),并以一条携带完整模型可见 `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/code-mode.ts b/packages/core/tools/src/code-mode.ts index 22a660a8ef..3c8e8ca024 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -120,29 +120,23 @@ 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. When no runtime is mounted, or one whose language - * has no renderer is, the schema harvest degrades to {@link TYPESCRIPT_FLAVOR} - * (a doc-only path — a real assembly always mounts a valid runtime, and - * `requireCodeRuntime` rejects an invalid language there first). A mounted - * runtime whose language passes that guard but is absent from this table fails - * loud, keeping this table coupled to `SDK_RENDERERS`. + * 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 + * 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. */ -function resolveFlavor(requireRuntime: () => CodeRuntime): RunCodeFlavor { - let runtime: CodeRuntime - try { - runtime = requireRuntime() - } catch { - // Reached only by the static schema harvest (doc catalog), which never - // feeds a model: either no runtime is mounted, or requireRuntime rejected - // a language with no renderer. Both degrade to the TS default here; a real - // assembly hits requireCodeRuntime's loud rejection before this runs. +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. return TYPESCRIPT_FLAVOR } // Own-property read: a language like `toString`/`constructor` would otherwise // resolve an inherited Object.prototype member as a flavor. const flavor = RUN_CODE_FLAVORS[runtime.language] - /* v8 ignore next 3 -- requireRuntime rejects a language absent from SDK_RENDERERS, whose keys - mirror RUN_CODE_FLAVORS; the guard is defense-in-depth against the two tables drifting. */ 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)}`) } @@ -287,6 +281,12 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue } 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" (the doc-catalog harvest, + * degrade to TS) 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`). */ maxParallel: number /** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */ @@ -305,7 +305,7 @@ export interface RunCodeBridgeOptions { * @returns the registry-ready definition. */ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition { - const { requireRuntime, maxParallel, shapeDispatchLog } = options + const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options const definition = defineTool({ name: RUN_CODE_NAME, // The description and `code` parameter description are placeholders here: @@ -668,14 +668,14 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge // is the least invasive point that still emits the loaded runtime's language. Object.defineProperty(definition, 'description', { enumerable: true, - get: () => resolveFlavor(requireRuntime).description, + get: () => resolveFlavor(peekRuntime).description, }) Object.defineProperty(definition, 'parameters', { enumerable: true, // Recompile through the same spec→schema projection defineTool used, so // the emitted shape can never drift from the validated one. get: () => parameterSchemaSpecToJsonSchema({ - code: { type: 'string', required: true, description: resolveFlavor(requireRuntime).codeDescription }, + code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription }, description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, }) as unknown as Record, }) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5f89fa23c6..22569faa6c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -768,6 +768,7 @@ export class ToolRegistry extends Service { ? undefined : createRunCodeTool(this, { requireRuntime: () => this.requireCodeRuntime(), + peekRuntime: () => this.ctx.get('codeRuntime'), maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls), shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), }) @@ -778,9 +779,9 @@ export class ToolRegistry extends Service { order: SDK_SECTION_ORDER, // Regenerate from the calling scope's visible tools in stable order, // picking the renderer that matches the loaded runtime's language. - // `requireCodeRuntime` already validated the language is in the - // table, so the fallback here is defense-in-depth against a caller - // that bypassed the guard (impossible under normal composition). + // `requireCodeRuntime` already validated the language is in the table, + // so the guard below is defense-in-depth against a caller that bypassed + // it (impossible under normal composition). text: (context) => { const runtime = this.requireCodeRuntime() // Own-property read: a language like `toString`/`constructor` would @@ -802,15 +803,17 @@ export class ToolRegistry extends Service { */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { const view = this.view(scope) - const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) if (this.mode === 'native') { + const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) return { schemas, knownNames: [...view.knownNames] } } - // Redundant with the per-getter resolveFlavor path (schemaOf's run_code - // description/parameters getters call requireCodeRuntime again): kept as a - // single explicit gate so a mode collapse rejects here regardless of - // whether any getter runs. The call is idempotent (ctx.get + Object.hasOwn). + // Validate the runtime language BEFORE projecting schemas: schemaOf reads + // run_code's language-aware description/parameters getters, whose own + // flavor-table guard would otherwise surface first. This keeps the + // renderer-table rejection the canonical assembly-time error for a + // language with no SDK renderer. this.requireCodeRuntime() + const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) if (this.mode === 'code') { return { schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME), diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 80c1ee7dc5..49a01b5452 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -21,22 +21,24 @@ import type { ToolSdkSchema } from './ts-types.ts' const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ /** - * Python 3.x soft-keyword-inclusive reserved set. A tool named ``class`` or - * ``lambda`` is legal on the wire but not as an attribute (``tools.class`` - * would be a SyntaxError in the model program), so we render it under - * subscript access — the model still reaches every tool without collisions. - * Underscore-leading names (``_x``, ``__class__``) are also subscript-only: - * dunders resolve on ``object`` before the proxy's fallback hook, and the - * subscript path is the one guaranteed bridge route for them. - * The same set rejects an argument field whose name would be an illegal - * class-syntax `TypedDict` attribute, degrading that object to - * ``dict[str, Any]``. + * Python hard keywords: reserved everywhere, so a tool or field named + * ``class`` or ``lambda`` is legal on the wire but not as an attribute + * (``tools.class`` would be a SyntaxError in the model program) and not as a + * 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 + * 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. */ const RESERVED = new Set([ 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', - 'return', 'try', 'while', 'with', 'yield', 'match', 'case', + 'return', 'try', 'while', 'with', 'yield', // Not a keyword, but CPython refuses to ASSIGN it at compile time // (`SyntaxError: cannot assign to __debug__`), which is what a TypedDict // field, a parameter name, and a keyword argument all are. @@ -310,13 +312,14 @@ function renderType(schema: unknown, className: string, state: RenderState): str break } case 'object': { - const properties = node.properties - if (typeof properties !== 'object' || properties === null) { - state.typing.add('Any') - finish('dict[str, Any]') - break - } - const entries = Object.entries(properties as Record) + // 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]`. + const entries = Object.entries((node.properties ?? {}) as Record) // 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 diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index ca488738ce..3ded3a7755 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -373,13 +373,27 @@ describe('mode-aware wire contribution', () => { expect(codeParam.description).toBe('The program: the body of an async Python function.') }) - it('fails loud when the runtime language has no run_code schema flavor', async () => { - // A language with an SDK renderer registered but (hypothetically) no schema - // flavor would fail here; a language with neither fails earlier at - // requireCodeRuntime. Both guards keep the two tables coupled. - const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } }) - registerEcho(ctx) - await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/) + 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. + 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"/) + }) + + 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). + const { ctx } = await setup({ mode: 'code', runtime: false }) + const definition = ctx.tools.get(RUN_CODE_NAME) + expect(definition?.description).toContain('Execute a TypeScript program') + const params = definition?.parameters as { properties: { code: { description: string } } } + expect(params.properties.code.description).toBe('The program: the body of an async TypeScript function.') }) it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 93b9990ac3..b9a244594d 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -260,6 +260,59 @@ 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)', () => { + const tool: ToolSdkSchema = { + name: 'search', + description: 'Soft keywords as fields.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + match: { type: 'string' }, + case: { type: 'boolean' }, + type: { type: 'string' }, + }, + required: ['match'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + // The object keeps its shape rather than degrading to dict[str, Any]. + expect(text).toContain('class SearchArgs(TypedDict):') + expect(text).toContain('match: str') + expect(text).toContain('case: NotRequired[bool]') + expect(text).toContain('type: NotRequired[str]') + expect(text).not.toContain('dict[str, Any]') + }) + + 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 + // and the TS renderer read it. It must not degrade to a permissive dict. + const tool: ToolSdkSchema = { + name: 'closed', + description: 'Closed empty object with omitted properties.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { inner: { type: 'object', additionalProperties: false } }, + required: ['inner'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toMatch(/class ClosedArgsInner\(TypedDict\):\n pass/) + expect(text).toContain('inner: ClosedArgsInner') + expect(text).not.toContain('dict[str, Any]') + }) + + it('degrades an open object with omitted properties to dict[str, Any]', () => { + // An OPEN empty object (default additionalProperties) is any dict. + const type = jsonSchemaToPy({ type: 'object', properties: {} }) + expect(type).toBe('dict[str, Any]') + expect(jsonSchemaToPy({ type: 'object' })).toBe('dict[str, Any]') + }) + it('renders docstrings for descriptions and orders emissions lexicographically', () => { const text = renderToolsSdkPy([bash, exotic]) expect(text).toContain('"""Run a shell command."""') From 98f8e53c4826b10a213655fc156be20cfd38d165 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:09:52 +0800 Subject: [PATCH 008/130] docs(tools): make the run_code catalog note language-neutral The gen-tool-catalog manifest note for dsh-tools still said the SDK section is TypeScript; the SDK language now follows ctx.codeRuntime.language. Reword to "a generated SDK section in the loaded runtime's language" and regenerate docs/tool-catalog.md. --- docs/tool-catalog.md | 4 ++-- scripts/gen-tool-catalog.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7d6fa79dea..c3588f23ee 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -16,7 +16,7 @@ This table connects model-visible tool names to the plugin package and service s | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | @@ -136,7 +136,7 @@ Execute a TypeScript program against the available tools. Write the BODY of an a Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts) -Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. +Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. ## `@deepseek-ai/dsh-plan-mode` diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index a77bdb6d95..b5b8e1371b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -179,7 +179,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ toolsConfig: { mode: 'code' }, async mount() {}, note: - 'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.', + 'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime\'s language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.', }, { pkg: '@deepseek-ai/dsh-plan-mode', From 59affddfc5cc5b7e70b864031eb7d647d832d883 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 13:55:28 +0800 Subject: [PATCH 009/130] 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 Promise>; 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 Promise>; 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> = { 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, 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 Date: Sun, 2 Aug 2026 13:58:20 +0800 Subject: [PATCH 010/130] 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 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 Date: Sun, 2 Aug 2026 14:31:29 +0800 Subject: [PATCH 011/130] 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 + 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 - 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) - // 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) + // 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 = { 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 Date: Sun, 2 Aug 2026 14:50:01 +0800 Subject: [PATCH 012/130] 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 + /** Next collision counter per capped base, so allocation is amortized O(1) instead of rescanning from `2`. */ + readonly nextClassCounter: Map readonly typing: Set } @@ -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 Date: Sun, 2 Aug 2026 15:24:10 +0800 Subject: [PATCH 013/130] 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() + 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 = { 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 = { 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 Date: Sun, 2 Aug 2026 15:35:19 +0800 Subject: [PATCH 014/130] 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, 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() - 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 = { type: 'array' } + const fn = Object.assign(function () {}, {}) as Record & (() => void) + ;(fn as Record).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 = { 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 = { 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 Date: Sun, 2 Aug 2026 15:51:23 +0800 Subject: [PATCH 015/130] 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, 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 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 = { 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 = { 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 = { 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 = { 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 = { 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 = {} + 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 Date: Sun, 2 Aug 2026 16:25:01 +0800 Subject: [PATCH 016/130] 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, 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, 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() - 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 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) // 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 = { 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 = { 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 = { 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 = { type: 'array' } - const fn = Object.assign(function () {}, {}) as Record & (() => void) - ;(fn as Record).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 = { 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 = { 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 = { 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 = { 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 = { 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 = {} - 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 = { 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 Date: Sun, 2 Aug 2026 16:42:32 +0800 Subject: [PATCH 017/130] 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` 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, 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, 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 - 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 - 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) + 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 Date: Sun, 2 Aug 2026 16:59:50 +0800 Subject: [PATCH 018/130] 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 Date: Sun, 2 Aug 2026 17:11:36 +0800 Subject: [PATCH 019/130] 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> = { typescript: renderToolsSdk, From b0e405a679647b37c36d2c3811ae4c306d7520cb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 17:22:38 +0800 Subject: [PATCH 020/130] 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 = { 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 Date: Sun, 2 Aug 2026 17:36:04 +0800 Subject: [PATCH 021/130] 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 = { 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 = { 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 Date: Sun, 2 Aug 2026 17:48:33 +0800 Subject: [PATCH 022/130] 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 = { 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 Date: Mon, 3 Aug 2026 19:49:30 +0800 Subject: [PATCH 023/130] 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 "" 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 "" 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 "" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装;观察状态绝不会显示。 +该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "" 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: `. 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 `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. +Failures are normalized as `Error: `. 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 `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( 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: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和策略模板在各自包的 README 中逐字列出。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( 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> = { + 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 Date: Mon, 3 Aug 2026 19:49:38 +0800 Subject: [PATCH 024/130] 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 Date: Wed, 5 Aug 2026 11:53:17 +0800 Subject: [PATCH 025/130] 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 Date: Wed, 5 Aug 2026 12:43:35 +0800 Subject: [PATCH 026/130] 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 } + +/** 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 ``` -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 = z.object({ + mode: z.union(TELEMETRY_MODES).default('FULL'), exporter: z.any(), processor: z.any(), }) @@ -76,22 +93,32 @@ const SEVERITY: Record { + 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 { - 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 { 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 { return paths.flat() } +async function readFixtureOutput(cwd: string): Promise { + 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 }[] + 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` 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` 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` 记录每个会话已交接(而非已投递)的最高 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` 记录每个会话已交接(而非已投递)的最高 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() * 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() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap>() + /** Redacted records retained until {@link release}; weak keys do not extend session lifetime. */ + private readonly held = new WeakMap() /** * @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 @@ -153,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend { abstract shutdown(): Promise } -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 Date: Wed, 5 Aug 2026 12:47:53 +0800 Subject: [PATCH 027/130] 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 Date: Wed, 5 Aug 2026 12:59:20 +0800 Subject: [PATCH 028/130] 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 + 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 Date: Wed, 5 Aug 2026 13:15:04 +0800 Subject: [PATCH 029/130] 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 Date: Wed, 5 Aug 2026 14:02:47 +0800 Subject: [PATCH 030/130] 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, + 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 Date: Wed, 5 Aug 2026 14:03:43 +0800 Subject: [PATCH 031/130] 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 Date: Wed, 5 Aug 2026 14:05:02 +0800 Subject: [PATCH 032/130] 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).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 Date: Wed, 5 Aug 2026 14:59:52 +0800 Subject: [PATCH 033/130] 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 = { 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 = { 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 Date: Wed, 5 Aug 2026 15:55:42 +0800 Subject: [PATCH 034/130] 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 Date: Wed, 5 Aug 2026 15:59:15 +0800 Subject: [PATCH 035/130] 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 = { 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 Date: Wed, 5 Aug 2026 16:31:07 +0800 Subject: [PATCH 036/130] 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 Date: Wed, 5 Aug 2026 16:46:23 +0800 Subject: [PATCH 037/130] 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, + 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 Date: Wed, 5 Aug 2026 17:01:51 +0800 Subject: [PATCH 038/130] 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 Date: Wed, 5 Aug 2026 17:15:35 +0800 Subject: [PATCH 039/130] 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 \`; 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 Date: Wed, 5 Aug 2026 17:28:58 +0800 Subject: [PATCH 040/130] 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 Date: Wed, 5 Aug 2026 17:45:38 +0800 Subject: [PATCH 041/130] 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 Date: Wed, 5 Aug 2026 17:59:48 +0800 Subject: [PATCH 042/130] 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 Date: Wed, 5 Aug 2026 18:21:43 +0800 Subject: [PATCH 043/130] 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, 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, + 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, - 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, - 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 9bba851a62c62a01167b0f02480fa3eb21200b7f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 5 Aug 2026 18:42:43 +0800 Subject: [PATCH 044/130] 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 Date: Wed, 5 Aug 2026 19:01:18 +0800 Subject: [PATCH 045/130] 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 Date: Wed, 5 Aug 2026 19:07:10 +0800 Subject: [PATCH 046/130] 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 = { 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 72991bbcdb78eef6986de131ac811ae857d03ca5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 5 Aug 2026 19:17:34 +0800 Subject: [PATCH 047/130] 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, + 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 Date: Wed, 5 Aug 2026 19:22:42 +0800 Subject: [PATCH 048/130] 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 = { 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 Date: Wed, 5 Aug 2026 19:37:11 +0800 Subject: [PATCH 049/130] 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 = { 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 Date: Wed, 5 Aug 2026 19:44:05 +0800 Subject: [PATCH 050/130] 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 Date: Wed, 5 Aug 2026 20:03:39 +0800 Subject: [PATCH 051/130] 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 = 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 Date: Wed, 5 Aug 2026 20:06:06 +0800 Subject: [PATCH 052/130] 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 Date: Wed, 5 Aug 2026 20:39:11 +0800 Subject: [PATCH 053/130] 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 Date: Wed, 5 Aug 2026 21:08:24 +0800 Subject: [PATCH 054/130] 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 ZzxArgs`, 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 Date: Wed, 5 Aug 2026 21:33:30 +0800 Subject: [PATCH 055/130] 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 Date: Wed, 5 Aug 2026 21:53:32 +0800 Subject: [PATCH 056/130] 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 Date: Wed, 5 Aug 2026 22:07:03 +0800 Subject: [PATCH 057/130] 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 Date: Wed, 5 Aug 2026 22:25:45 +0800 Subject: [PATCH 058/130] 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 Date: Wed, 5 Aug 2026 22:43:42 +0800 Subject: [PATCH 059/130] 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 Date: Wed, 5 Aug 2026 22:56:32 +0800 Subject: [PATCH 060/130] 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 Date: Wed, 5 Aug 2026 23:09:06 +0800 Subject: [PATCH 061/130] 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 Date: Wed, 5 Aug 2026 23:25:42 +0800 Subject: [PATCH 062/130] 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` 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` 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`,因为 `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` 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 = { typescript: TYPESCRIPT_FLAVOR, python: PYTHON_FLAVOR, -} +} satisfies Record /** * 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> = { typescript: renderToolsSdk, python: renderToolsSdkPy, -} +} satisfies Record string> export { defineTool, From 9b3a0982c84d3d131c287f4b5308bdebe6879a24 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 5 Aug 2026 23:28:37 +0800 Subject: [PATCH 063/130] 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 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 Date: Wed, 5 Aug 2026 23:44:26 +0800 Subject: [PATCH 064/130] 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` 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` 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`,因为 `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`,因为 `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> = { From b2c187279954e7c48a0a414332aa7f3203542d80 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 6 Aug 2026 00:07:10 +0800 Subject: [PATCH 065/130] 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` 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` 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`,因为 `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`,因为 `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 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> = { 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 Date: Thu, 6 Aug 2026 00:22:05 +0800 Subject: [PATCH 066/130] 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` 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` 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`,因为 `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`,因为 `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 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> = { 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 Date: Thu, 6 Aug 2026 00:37:51 +0800 Subject: [PATCH 067/130] 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 Date: Thu, 6 Aug 2026 00:51:09 +0800 Subject: [PATCH 068/130] 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 `:code:`, 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 `:code:`,按提交顺序编号),并以一条携带完整模型可见 `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 Date: Thu, 6 Aug 2026 01:03:03 +0800 Subject: [PATCH 069/130] 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 Date: Thu, 6 Aug 2026 01:16:09 +0800 Subject: [PATCH 070/130] 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 Date: Thu, 6 Aug 2026 01:38:26 +0800 Subject: [PATCH 071/130] 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` 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` 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`,因为 `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`,因为 `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 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> = { 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 Date: Thu, 6 Aug 2026 02:09:27 +0800 Subject: [PATCH 072/130] 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 Date: Thu, 6 Aug 2026 02:22:51 +0800 Subject: [PATCH 073/130] 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 Date: Thu, 6 Aug 2026 02:33:25 +0800 Subject: [PATCH 074/130] 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 af652c949f24a0920230e8a7455878f416106b75 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 12:07:28 +0800 Subject: [PATCH 075/130] 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 `_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 `_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` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_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 `_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,以便使用提供方原生凭据发现。 + +只有当联接所得的行识别出该页面派生的精确 `_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 `_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 `_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 `_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 `_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 没有引用时便派生 `_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 没有引用时便派生 `_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 指向页面派生的 `_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,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 +- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_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 -/** 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, + api: Pick, controller: ModelsSettingsStore, - target: { settingsNs: string; settingsPath: readonly string[] }, + target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { - 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(undefined) const [deleting, setDeleting] = useState(false) + const [deleteFailure, setDeleteFailure] = useState(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 { @@ -296,9 +331,16 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { @@ -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)} )} - /> + > + {deleteFailure === undefined ? null :

{deleteFailure}

} +
) } 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 `_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(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(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( + () => 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 => { 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 ( <>
@@ -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) {} - /** - * 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 mutate?: ReturnType set?: ReturnType + unset?: ReturnType } = {}) { 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[0] async function mountSection(overrides: Parameters[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[0] = {}) t, } const view = render() - 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(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(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(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(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('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('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('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[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 f330b6ae796e22df398fea7d9ccdeb9b155d17b8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 12:51:18 +0800 Subject: [PATCH 076/130] 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 099b903ac6ccd124acb653db4d528dd51f9b7c00 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 13:15:09 +0800 Subject: [PATCH 077/130] 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(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(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( () => 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(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 bb920b32e004926157b3d4e842d9d419b2b1f5ad Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 13:59:05 +0800 Subject: [PATCH 078/130] 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 ``, 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 结果格式再引入一个解析器。 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 + + 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('') + 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 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. Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. " +- 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) => ( ) +/** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */ +export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + /** ic_ds_question_outline_14 (figma extract): ring + question glyph. */ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => ( 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 name 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).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 + case 'stopped': return + default: return + } +} + +/** 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 ( + + + + ) +} + +/** + * 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): void => { + if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + toggleExpand() + } + const leading = open + ? + : expandable + ? ( + <> + {leadingFor(model.state)} + + + ) + : leadingFor(model.state) + return ( +
+
+ {leading} + {status !== null ? {status} : null} + Skill + + + {summary} + +
+ {open ? ( +
+
+
{t('row.instructions')}
+
{model.output}
+
+ {inspect !== undefined ? ( + + ) : null} +
+ ) : null} +
+ ) +} 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 + +/** 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 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[0] + +const t: SkillRowProps['t'] = makeTranslate(zh, commonZh) + +afterEach(cleanup) + +function settled(over: Partial = {}): 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() + 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() + 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() + 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() + 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() + expect(stoppedView.container.textContent).toContain('skill 加载已中止') + expect(stoppedView.container.querySelector('[data-state="warning"]')).not.toBeNull() + cleanup() + + const structuredView = render() + fireEvent.click(screen.getByRole('button')) + expect(structuredView.container.textContent).toContain('"type": "reasoning"') + cleanup() + + render() + 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() + expect(invalid.container.textContent).toContain('{"name":') + cleanup() + + const scalar = render() + expect(scalar.container.textContent).toContain('"raw-name"') + cleanup() + + const emptyName = render() + expect(emptyName.container.textContent).toContain('{"name":""}') + cleanup() + + const blank = render() + 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 1ed5dc81d249a7d391fc9c03da3898fad0706517 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 14:22:54 +0800 Subject: [PATCH 079/130] 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 Date: Thu, 6 Aug 2026 14:31:19 +0800 Subject: [PATCH 080/130] 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 Date: Thu, 6 Aug 2026 14:40:08 +0800 Subject: [PATCH 081/130] 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 + const icon = leadingFor(state) + if (!expandable) return icon + return ( + <> + {icon} + + + ) +} + /** 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 - ? - : expandable - ? ( - <> - {leadingFor(model.state)} - - - ) - : leadingFor(model.state) + const leading = disclosureLeading(model.state, open, expandable) return (
Date: Thu, 6 Aug 2026 14:50:21 +0800 Subject: [PATCH 082/130] 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 Date: Thu, 6 Aug 2026 14:57:30 +0800 Subject: [PATCH 083/130] 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` 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` 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` 记录每个会话已交接(而非已投递)的最高 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` 记录每个会话已交接(而非已投递)的最高 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() /** * 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() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap>() - /** Redacted records retained until {@link release}; weak keys do not extend session lifetime. */ - private readonly held = new WeakMap() - /** * @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 @@ -158,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend { abstract shutdown(): Promise } -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 Date: Thu, 6 Aug 2026 15:03:52 +0800 Subject: [PATCH 084/130] 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 Date: Thu, 6 Aug 2026 15:34:36 +0800 Subject: [PATCH 085/130] 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 = 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().toEqualTypeOf() + expectTypeOf<'FULL'>().not.toExtend() + expectTypeOf().toExtend() + }) + 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): 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 Date: Thu, 6 Aug 2026 16:15:58 +0800 Subject: [PATCH 086/130] 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 Date: Thu, 6 Aug 2026 16:27:27 +0800 Subject: [PATCH 087/130] 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 Date: Thu, 6 Aug 2026 16:32:46 +0800 Subject: [PATCH 088/130] 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 Date: Wed, 5 Aug 2026 16:40:29 +0800 Subject: [PATCH 089/130] 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 + 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 { + 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 }> { + 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 { +export function assistantActionsSeqs( + nodes: readonly ConversationNode[], + turnEnds: ReadonlyMap, +): ReadonlySet { const lastByTurn = new Map() 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() + // 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 Date: Wed, 5 Aug 2026 16:55:20 +0800 Subject: [PATCH 090/130] 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() - // 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 Date: Thu, 6 Aug 2026 16:44:04 +0800 Subject: [PATCH 091/130] 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 `_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 `_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 `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。 +只有当联接所得的行识别出该页面派生的精确 `_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 `_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 `_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 没有引用时便派生 `_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 没有引用时便派生 `_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(undefined) const [deleting, setDeleting] = useState(false) const [deleteFailure, setDeleteFailure] = useState(undefined) + const [savedTarget, setSavedTarget] = useState(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 {

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} + {savedTarget === undefined + ? null + : ( +

+ {providerCopy(t('savedProvider'), savedTarget)} +

+ )}
    {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) }} /> ) } 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 (
  • - {row.entry.displayName} + + {row.entry.displayName} + {credentialConfigured + ? ( + + ) + : credentialMissing + ? ( + + ) + : null} + @@ -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')} @@ -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) }} />
    ) @@ -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() + + 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 Date: Thu, 6 Aug 2026 17:21:13 +0800 Subject: [PATCH 092/130] 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 ``, 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 ``, 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 结果格式再引入一个解析器。 +无论跨越分页,还是已安装的 skill 目录发生变化,冷回放都保持确定性;在用户显式展开指令前,transcript 保持紧凑。通用配对注解还可防止其他键控工具行和结果 presenter 在分页边界改变身份,同时无需持久化重复数据。专用卡片有意显示工具完整封装的输出,而不是只提取 ``,从而原样保留模型实际收到的内容,也避免为 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> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onModels: (payload: unknown) => Promise> = () => 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 ( - - - - ) -} - /** 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} > - + Inspect )} 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 && ( )} 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() - // 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) => ( ) +/** inspect_outline_12 (shared tool-row trajectory affordance glyph) */ +export const IconInspectOutline12 = ({ size = 12, className }: IconProps) => ( + + + +) + /** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */ export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => ( 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 ( - - - - ) -} - /** * 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 (
    {leading} {status !== null ? {status} : null} @@ -167,7 +160,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { {inspect !== undefined ? ( ) : 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() - 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 { + const calls = new Map() + 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, + 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 -/** 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> = 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> = z.object({ event: sessionEventSchema, view: toolEventViewSchema.optional(), + call: historyToolCallSchema.optional(), }) as unknown as z.ZodType> /** 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 Date: Thu, 6 Aug 2026 17:31:25 +0800 Subject: [PATCH 093/130] 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 ( + + ) +} + /** * 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 (
  • - { closeEditor(changed, target) }} - /> + {renderProviderEditor({ + target, + namespace, + api, + t, + readOnly: !state.writable, + onClose: (changed) => { closeEditor(changed, target) }, + })}
  • ) } @@ -312,18 +330,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
{open - ? ( - { closeEditor(changed, target) }} - /> - ) + ? renderProviderEditor({ + target, + namespace, + api, + t, + readOnly: !state.writable, + onClose: (changed) => { closeEditor(changed, target) }, + }) : null} ) 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[0] = {}) { t, } render() - 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 Date: Thu, 6 Aug 2026 17:39:43 +0800 Subject: [PATCH 094/130] 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 = 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().toEqualTypeOf() expectTypeOf<'FULL'>().not.toExtend() expectTypeOf().toExtend() + 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 Date: Thu, 6 Aug 2026 17:46:18 +0800 Subject: [PATCH 095/130] 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 `" 条排队消息"` 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 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `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({ ) 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 ? : } - {showBranch && onBranch !== undefined && ( + {onBranch !== undefined && ( {/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
{ 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 :

{t(keyFailure)}

}
{t('customized')} @@ -396,7 +404,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { { 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 Date: Thu, 6 Aug 2026 22:42:56 +0800 Subject: [PATCH 109/130] 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 `, 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 `,于是一个本地的 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 `, 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 `,于是一个本地的 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 Date: Thu, 6 Aug 2026 23:15:08 +0800 Subject: [PATCH 110/130] 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 1019f149c4e4ad2b75d7c9a15e0976ceaa05e7c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 11:02:38 +0800 Subject: [PATCH 111/130] =?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 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 :

{t(keyFailure)}

} + {/* 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 + :

{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}

} 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 /** Section copy. */ @@ -314,8 +321,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
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 Date: Fri, 7 Aug 2026 11:04:02 +0800 Subject: [PATCH 112/130] 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 Date: Fri, 7 Aug 2026 11:16:20 +0800 Subject: [PATCH 113/130] 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 Date: Fri, 31 Jul 2026 18:10:33 +0800 Subject: [PATCH 114/130] 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 = 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 = 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 = 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 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 Date: Fri, 31 Jul 2026 18:17:51 +0800 Subject: [PATCH 115/130] 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 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 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 From eb03aa86fec91dd92cb303834596141c0ad1f566 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:30:32 +0800 Subject: [PATCH 116/130] =?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 { const bindings = new Map() 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 = new Set([ 'console', @@ -54,8 +58,11 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet = 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 Date: Fri, 31 Jul 2026 21:54:57 +0800 Subject: [PATCH 117/130] 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 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 Date: Sat, 1 Aug 2026 01:55:10 +0800 Subject: [PATCH 118/130] 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 Date: Sat, 1 Aug 2026 05:54:04 +0800 Subject: [PATCH 119/130] 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 Date: Sat, 1 Aug 2026 13:57:49 +0800 Subject: [PATCH 120/130] 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 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 = 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 = new Set([ 'name', 'message', 'stack', @@ -65,9 +65,11 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet = 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 Date: Sat, 1 Aug 2026 15:53:48 +0800 Subject: [PATCH 121/130] 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 Date: Fri, 7 Aug 2026 11:35:23 +0800 Subject: [PATCH 122/130] 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 Date: Fri, 7 Aug 2026 11:51:18 +0800 Subject: [PATCH 123/130] 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> = { + '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 { + 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 { + return new Promise((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 + 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 { + 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 { + 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 , /goal pause, /goal clear"}} +{"type":"agent/inbox/spliced","seq":6,"time":1786073817217,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"\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"}],"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":"\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"}],"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":"\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"}],"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":"\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"}],"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":"\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"}],"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":"\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"}],"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 , /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 , /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 Date: Fri, 7 Aug 2026 12:20:24 +0800 Subject: [PATCH 124/130] 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 Date: Fri, 7 Aug 2026 12:50:23 +0800 Subject: [PATCH 125/130] 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 Date: Fri, 7 Aug 2026 13:26:40 +0800 Subject: [PATCH 126/130] 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>> 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 { + 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 { + 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() 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(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(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(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(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(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(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(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(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 Date: Thu, 6 Aug 2026 11:09:09 +0800 Subject: [PATCH 127/130] 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 { 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 { + 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(resolve => requestAnimationFrame(() => { resolve() })) + } + }, deltaY) + await nextPaint(page) +} + async function wheelToHistoryStart(page: Page): Promise { 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(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(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) { 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() 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() 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 Date: Fri, 7 Aug 2026 13:56:42 +0800 Subject: [PATCH 128/130] 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 ``, 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 ``, 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 结果格式再引入一个解析器。 +即使已安装的 skill 目录发生变化,冷回放仍保持确定性;在用户显式展开指令前,transcript 保持紧凑。仅含结果的 history 页有意使用通用后备路径;让这个边缘情况保持通用呈现,可以保留现有 history 协议,并将该功能限定在客户端呈现层。专用卡片有意显示工具完整封装的输出,而不是只提取 ``,从而原样保留模型实际收到的内容,也避免为 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> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onModels: (payload: unknown) => Promise> = () => 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() - // 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 { - const calls = new Map() - 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, - 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 -/** Paired tool/call metadata carried with a paged tool/result. */ -export const historyToolCallSchema: z.ZodType> = 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> = z.object({ event: sessionEventSchema, view: toolEventViewSchema.optional(), - call: historyToolCallSchema.optional(), }) as unknown as z.ZodType> /** 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 Date: Fri, 7 Aug 2026 14:03:11 +0800 Subject: [PATCH 129/130] 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 Date: Fri, 7 Aug 2026 16:15:10 +0800 Subject: [PATCH 130/130] 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 case 'stopped': return - default: return + default: return } } 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)