From 96a1749db6cc0c6912ce90204ac46cb01085e009 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 16:52:42 +0800 Subject: [PATCH 1/7] fix(ptc): preserve literal braces in generated SDK prompts --- ...bles-and-tool-guidance-ownership.i18n.yaml | 4 +-- ...t-variables-and-tool-guidance-ownership.md | 8 ++--- ...ariables-and-tool-guidance-ownership.zh.md | 8 ++--- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/system-prompt.i18n.yaml | 4 +-- docs/subsystems/system-prompt.md | 4 ++- docs/subsystems/system-prompt.zh.md | 4 ++- packages/core/system-prompt/README.i18n.yaml | 4 +-- packages/core/system-prompt/README.md | 6 ++-- packages/core/system-prompt/README.zh.md | 6 ++-- packages/core/system-prompt/src/index.ts | 12 +++++-- .../system-prompt/tests/system-prompt.spec.ts | 14 ++++++++ packages/core/tools/README.i18n.yaml | 4 +-- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/index.ts | 5 +-- .../core/tools/tests/fixtures/literal-sdk.ts | 20 ++++++++++++ packages/core/tools/tests/ptc.spec.ts | 32 ++++++++++++++++++- .../extensions/tool-cordis/src/api-catalog.ts | 4 +-- .../session/ptc-turn/cordis.snapshot.yml | 4 +++ snapshots/session/ptc-turn/cordis.yml | 4 +++ .../ptc-turn/system-prompt.expected.md | 5 +++ 24 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 packages/core/tools/tests/fixtures/literal-sdk.ts diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 611e38e8ba..57dba7f52b 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 35bb7c6fabc85ae6f93bdbb67e13910eea627ca3 -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 53d69cf45c02f6534334561b626d2c2ae6087c05 +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 8f5542cb09930e62fd1e26960166fbdd6d3f6745 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 348ae36cf6549d412adab1d3cdb5cc03f0badd01 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 35bb7c6fab..8f5542cb09 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -26,7 +26,7 @@ The assembled system prompt had four defects, all of one family: facts the harne ### Prompt variables -Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. +Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. Sections may set `interpolate: false` to preserve generated documents literally; `tools:sdk` does so because tool descriptions and schemas may document their own `{{…}}` syntax. `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. @@ -47,7 +47,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect - **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.) - **Inject the model name via the `agent/request` waterfall** — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model. - **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this decision cures. -- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. +- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. Leaving only unknown names unchanged would still substitute registered names inside tool documentation. - **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, reviving the hand-written-guidance-in-leaf-YAML drift. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. - **Resolving the provider at `apply` time (a load-order requirement)** and **section-only subagent wording (lazily resolved at assemble)** — the alternatives to the provider-lifecycle events; both rejected in [the provider-lifecycle-events Agent Note](../../archived/architecture/2026-07-05-subagent-provider-lifecycle-events.md). @@ -60,7 +60,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect - The tui-agent prompt renders identity, persona with the interpolated model, then fs/shell/web guidance through one assembly path. - Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. -- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. +- In interpolated sections, unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. - Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. ## Consequences @@ -69,4 +69,4 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect - `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step, and one that SUPPLIES the model there (options.model unset — the loop's documented fallback) leaves the variable valueless at render, failing a `{{model}}` persona before the waterfall runs. Both have the same remedy, and it is the ownership rule itself: the plugin that owns the late-bound model fact states it early on the `system-prompt/assemble` waterfall (`assembly.variables['model'] = …`) — one owner, both statements; a loop test pins the supply path end-to-end. Accepted. - While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted. - Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, the loop survives — and it is an authoring error we WANT loud. -- No escape syntax for a literal `{{name}}` in prompt prose yet; add one if a real prompt ever needs it. +- Inline escapes remain unsupported in interpolated text; literal sections need no escaping. PTC unit tests cover both modes and runtime languages, and the recorded `ptc-turn` scenario preserves tool-template examples in the model-visible prompt. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 53d69cf45c..348ae36cf6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -26,7 +26,7 @@ Status: implemented ### 提示词变量 -插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 +插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。段可设置 `interpolate: false` 来原样保留生成的文档;`tools:sdk` 使用此设置,因为工具描述和 schema 可能会介绍自身的 `{{…}}` 语法。 `dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下面的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。 @@ -47,7 +47,7 @@ Status: implemented - **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。) - **通过 `agent/request` waterfall 注入模型名称**:提示词文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在提示词中更早出现的声明。 - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。 -- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 +- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。仅保留未知名称仍会替换工具文档中的已注册名称。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。 - **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](../../archived/architecture/2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 @@ -60,7 +60,7 @@ Status: implemented - tui-agent 的提示词通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/shell/web 指导。 - fork 和 fresh subagent 的描述反映提供方是否继承已完成的对话轮次;工具随提供方生命周期变化而出现、消失和重新措辞。 -- 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。 +- 在启用插值的段中,未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。 - 快照回放与提示词无关:它按轮次和步骤索引已记录的分片流,不比较发出的请求。 ## 后果 @@ -69,4 +69,4 @@ Status: implemented - `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 - 当一个已绑定的提供方不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。 - 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望明确暴露的撰写错误。 -- 目前没有在提示词行文中转义字面 `{{name}}` 的语法;如果真实提示词确实需要,再行添加。 +- 插值文本仍不支持行内转义;字面文本段无需转义。PTC 单元测试覆盖两种模式和运行时语言,录制的 `ptc-turn` 场景在模型可见的提示词中保留工具模板示例。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2f4b0e6e79..ebb600e7d4 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4e92bbfcf4bcf190b9b17436eefc3255dda9132a -config-catalog.zh.md: df4b107f28040962f38802a77f8b91c47ad2931d +config-catalog.md: fd6f249775957f0c6ce74526ba98f5b461af548d +config-catalog.zh.md: 22aa87aa64eac626304a28227e172aa6952b087f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4e92bbfcf4..fd6f249775 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2607,7 +2607,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index df4b107f28..22aa87aa64 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2609,7 +2609,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index 96577399bd..c4fb972a1d 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: 516e3347c06882bfd3ff42638d77a2acb3aca753 -system-prompt.zh.md: 611ea0e9bdd81585efd7106012edd3d17e7bdd8e +system-prompt.md: 898520e8fc88a9fafd8429723fc48bad3b4ba210 +system-prompt.zh.md: 82e557a309a48f546d7c7fa5a8c1097152b4d145 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 516e3347c0..898520e8fc 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -56,9 +56,11 @@ interface PromptSection { /** * Static text or a provider evaluated at each assembly with that assembly's * {@link AssembleContext}. The text may reference `{{variable}}`s — they are - * interpolated later, by {@link renderPrompt}. + * interpolated later, by {@link renderPrompt}, unless `interpolate` is false. */ readonly text: string | ((context: AssembleContext) => string) + /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */ + readonly interpolate?: boolean /** * Treat this contribution as the complete system prompt. Assembly still * runs the cooperative waterfall so tools, contexts, and variables can be diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index 611ea0e9bd..82e557a309 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -56,9 +56,11 @@ interface PromptSection { /** * Static text or a provider evaluated at each assembly with that assembly's * {@link AssembleContext}. The text may reference `{{variable}}`s — they are - * interpolated later, by {@link renderPrompt}. + * interpolated later, by {@link renderPrompt}, unless `interpolate` is false. */ readonly text: string | ((context: AssembleContext) => string) + /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */ + readonly interpolate?: boolean /** * Treat this contribution as the complete system prompt. Assembly still * runs the cooperative waterfall so tools, contexts, and variables can be diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index afa3965820..0190a5a339 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md -README.md: e98c1be171d7dbb16cabf1289a20be8922e5076b -README.zh.md: 2408ec1dbb730bd5f085f3467a5359f5900efebc +README.md: 9be48304c0ecdbcfa4b73a753d5565a60882038c +README.zh.md: 416eb6feb3d423c61e911b29c21ad8e0013c5e43 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index e98c1be171..9be48304c0 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -63,6 +63,8 @@ ctx.systemPrompt.section({ }) ``` +Set `interpolate: false` on a section to preserve its text literally, including `{{…}}` groups in generated tool documentation. Other sections interpolate variables by default. + ### Contribute a prompt variable Variables are referenced from section text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `model` and `cwd`; any plugin can register the facts it owns. @@ -102,7 +104,7 @@ The package is a registry plus a cooperative assembly pipeline. One `assemble()` ### Assembly and rendering -Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits. +Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references unless a section sets `interpolate: false`, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits. ### Scoping @@ -170,7 +172,7 @@ Prefix-stable while the visible schema set, rendering, and order are unchanged. These limits define when prompt assembly needs special care. They are current package constraints, not a task backlog. - **Deployment-authored prompt text is config/composition only** — this plugin owns the global persona prefix and suffix defaults, creator plugins may register agent-scoped shadows, and other sections come from the plugin that owns the fact; there is no end-user prompt-editing API. -- **No escape syntax for literal `{{…}}` braces** — every complete group is interpolated against registered variables; an escape is deferred until a real prompt needs one. +- **No inline escape syntax in interpolated text** — use `interpolate: false` when a whole section must preserve literal braces. - **`toolOrder` misconfiguration surfaces at prompt assembly (the first turn), not at boot** — only shape violations throw at config load. diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 2408ec1dbb..416eb6feb3 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -63,6 +63,8 @@ ctx.systemPrompt.section({ }) ``` +在段上设置 `interpolate: false` 可原样保留文本,包括生成的工具文档中的 `{{…}}` 组。其他段默认执行变量插值。 + ### 贡献提示词变量 变量在段文本中以 `{{name}}` 引用,并在每次组装时解析;带作用域变量会为该 agent 遮蔽同名全局变量。循环提供 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实。 @@ -102,7 +104,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd) ### 组装与渲染 -组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall(瀑布式事件)之前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。 +组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 对未设置 `interpolate: false` 的段插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall(瀑布式事件)之前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。 ### 作用域 @@ -170,7 +172,7 @@ schema token 在每次请求中重复。限制工具会为该 agent 移除其全 这些限制说明提示词组装何时需要特别留意。它们是当前包约束,不是任务积压。 - **部署方编写的提示词文本只来自配置/组合**:此插件拥有全局 persona 前缀与后缀默认值;创建方插件可以注册 agent 作用域的遮蔽项;其他段来自拥有相应事实的插件。不存在终端用户提示词编辑 API。 -- **没有表示字面量 `{{…}}` 花括号的转义语法**:每个完整组都会按已注册变量插值;只有实际提示词需要转义时才会实现。 +- **插值文本不支持行内转义语法**:整段需要保留字面花括号时,使用 `interpolate: false`。 - **`toolOrder` 配置错误在提示词组装(首轮)时出现,而不是启动时**:只有形状违规会在配置加载时抛出。 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index d83cc4d216..0d056ec1fc 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -61,9 +61,11 @@ export interface PromptSection { /** * Static text or a provider evaluated at each assembly with that assembly's * {@link AssembleContext}. The text may reference `{{variable}}`s — they are - * interpolated later, by {@link renderPrompt}. + * interpolated later, by {@link renderPrompt}, unless `interpolate` is false. */ readonly text: string | ((context: AssembleContext) => string) + /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */ + readonly interpolate?: boolean /** * Treat this contribution as the complete system prompt. Assembly still * runs the cooperative waterfall so tools, contexts, and variables can be @@ -89,6 +91,8 @@ export interface AssembledSection { name: string /** The resolved (but not yet interpolated) section text. */ text: string + /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */ + interpolate?: boolean } /** One resolved dynamic context contribution. */ @@ -264,7 +268,8 @@ export interface Config { /** * Interpolate strict `{{variable}}` references, drop empty sections, and join - * the rest with blank lines. Malformed, unknown, or undefined references throw; + * the rest with blank lines. Sections with `interpolate: false` retain literal + * text. Malformed, unknown, or undefined references in other sections throw; * a lone `{{` without any later `}}` is literal prose, and substituted values * are not scanned again. * @param assembly - the assembly whose sections and variables to render. @@ -272,7 +277,7 @@ export interface Config { */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections - .map(section => interpolate(section, assembly.variables, 'section')) + .map(section => section.interpolate === false ? section.text : interpolate(section, assembly.variables, 'section')) .filter(text => text.length > 0) .join('\n\n') } @@ -597,6 +602,7 @@ export class SystemPrompt extends Service { const assembled = { name: section.name, text: typeof section.text === 'function' ? section.text(context) : section.text, + ...section.interpolate !== undefined ? { interpolate: section.interpolate } : {}, } if (section.complete === true) completeSection = { ...assembled } return assembled diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 8e60ea2bec..3294c3179d 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -579,6 +579,20 @@ describe('SystemPrompt', () => { expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`) }) + it.each([false, true])('preserves literal section text with complete=%s', async (complete) => { + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt, { includeHarnessIdentity: false, personaPrefix: '{{model}}' }) + ctx.systemPrompt.variable('model', () => 'actual-model') + const text = '{{item}} {{model}} {{ model }} {{nested{{item}}}}' + ctx.systemPrompt.section({ name: 'literal', order: 1, text: () => text, interpolate: false, complete }) + expect(renderPrompt(await ctx.systemPrompt.assemble())) + .toBe(complete ? text : `actual-model\n\n${text}`) + } finally { + await ctx.fiber.dispose() + } + }) + it('lets a waterfall listener add or override variables before render', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index ddeec40191..b6933fd01f 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: 725e3e1a6a0a977e2e917561c61951a670a71ee4 -README.zh.md: 118849a2e58007cff694dcd07ab7b38b850d2a9e +README.md: 6c5d42bd7597d4f7715b50c71126bd645352dcff +README.zh.md: 6dff796455974f84b6a2facd6fb464976692be4b diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 725e3e1a6a..6c5d42bd75 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -170,7 +170,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -PTC 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 instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. +PTC 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 instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. ##### TypeScript PTC mode SDK instructions with bash diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 118849a2e5..6dff796455 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -170,7 +170,7 @@ ctx.tools.register(defineTool({ #### 模型看到什么 -PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 +PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 ##### 带 bash 的 TypeScript PTC mode SDK 说明 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index bef11baf26..b4d1a2a805 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -13,7 +13,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { UserMessage } from '@deepseek-ai/dsh-session' import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' +import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService // augmentation. The seam stays optional at runtime — see `serviceAsk`. @@ -864,10 +864,11 @@ export class ToolRuntime extends Service { * dropped from the rendered prompt. * @returns the section registration. */ - private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + private sdkSection(): PromptSection { return { name: 'tools:sdk', order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), + interpolate: false, // Regenerate from the calling scope's visible tools in stable order. text: (context) => { const mode = this.modeFor(context.scope) diff --git a/packages/core/tools/tests/fixtures/literal-sdk.ts b/packages/core/tools/tests/fixtures/literal-sdk.ts new file mode 100644 index 0000000000..4f0206456f --- /dev/null +++ b/packages/core/tools/tests/fixtures/literal-sdk.ts @@ -0,0 +1,20 @@ +/** Tool documentation containing literal template syntax for recorded PTC replay. */ + +import type { Context } from '@deepseek-ai/cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'literal-sdk' +export const inject = ['tools'] + +export function apply(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'template_echo', + description: 'Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution.', + parameters: { text: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute: args => Promise.resolve(args.text), + })) +} diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index 11bf14e01a..cfb651283e 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' @@ -117,6 +117,36 @@ async function runCode( } describe('mode-aware wire contribution', () => { + it.each([ + { mode: 'ptc', language: 'typescript' }, + { mode: 'both', language: 'typescript' }, + { mode: 'ptc', language: 'python' }, + { mode: 'both', language: 'python' }, + ] as const)('preserves literal braces in the $language SDK under $mode', async ({ mode, language }) => { + const { ctx, systemPrompt } = await setup({ mode, runtime: { language } }) + try { + systemPrompt.variable('model', () => 'actual-model') + const description = 'Expand {{item}} with {{model}} or {{ model }}.' + ctx.tools.register(defineTool({ + name: 'template', + description, + parameters: { value: { type: 'string', description, enum: ['{{item}}', '{{model}}'], required: true } }, + output: { + schema: { type: 'string', enum: ['{{item}}', '{{model}}'] }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute: args => Promise.resolve(args.value), + })) + const assembly = await systemPrompt.assemble() + const prompt = renderPrompt(assembly) + expect(prompt).toContain(description) + expect(prompt).toContain(assembly.sections.find(section => section.name === 'tools:sdk')?.text) + expect(prompt).not.toContain('actual-model') + } finally { + await ctx.fiber.dispose() + } + }) + it("mode 'native' contributes every schema, no run_code, no SDK section — and needs no runtime", async () => { const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false }) registerEcho(ctx) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4606b99848..39cfe12a54 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3698,7 +3698,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AssembledSection', - declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}', + declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n interpolate?: boolean;\n}', }, { name: 'AssistantMessage', @@ -4830,7 +4830,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly interpolate?: boolean;\n readonly complete?: boolean;\n}', }, { name: 'PromptSectionOrderName', diff --git a/snapshots/session/ptc-turn/cordis.snapshot.yml b/snapshots/session/ptc-turn/cordis.snapshot.yml index 5ef66b0f1e..72ac169e3c 100644 --- a/snapshots/session/ptc-turn/cordis.snapshot.yml +++ b/snapshots/session/ptc-turn/cordis.snapshot.yml @@ -1,5 +1,9 @@ # Keyless PTC mode combines the runtime/registry changes with the # DeepSeek-to-replay swap in one profile patch. +- insert: + - id: literal-sdk + name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' + - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true diff --git a/snapshots/session/ptc-turn/cordis.yml b/snapshots/session/ptc-turn/cordis.yml index 1ff5387957..a9f85a8e1c 100644 --- a/snapshots/session/ptc-turn/cordis.yml +++ b/snapshots/session/ptc-turn/cordis.yml @@ -1,6 +1,10 @@ # PTC mode adds `ctx.codeRuntime` and changes the registry to one wire tool, # `run_code`, plus its generated TypeScript SDK prompt. The demo and snapshot # recorder apply this profile patch; replay applies its sibling patch. +- insert: + - id: literal-sdk + name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' + - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' config: diff --git a/snapshots/session/ptc-turn/system-prompt.expected.md b/snapshots/session/ptc-turn/system-prompt.expected.md index dd648445e1..589b935ac2 100644 --- a/snapshots/session/ptc-turn/system-prompt.expected.md +++ b/snapshots/session/ptc-turn/system-prompt.expected.md @@ -191,6 +191,10 @@ interface ToolArgsMap { /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; } & Record; + /** Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution. */ + template_echo: { + text: string; + } & Record; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ todo_write: { /** The COMPLETE task list, replacing any previous list. */ @@ -466,6 +470,7 @@ interface ToolOutputMap { runId: string; output: JsonValue[]; }; + template_echo: string; todo_write: { todos: ({ content: string; From e4be27d1d9f3929d470c9bbd0f97c36e736972fa Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:13:08 +0800 Subject: [PATCH 2/7] test(ptc): align workspace snapshot with shared SDK prompt --- snapshots/session/ptc-workspace-context/cordis.snapshot.yml | 4 ++++ snapshots/session/ptc-workspace-context/cordis.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/snapshots/session/ptc-workspace-context/cordis.snapshot.yml b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml index 63cd3c497f..f8e5fa3228 100644 --- a/snapshots/session/ptc-workspace-context/cordis.snapshot.yml +++ b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml @@ -1,5 +1,9 @@ # Keyless replay counterpart of ptc-workspace-context.cordis.yml. It adds # PTC mode to the default filesystem suite and swaps in replay. +- insert: + - id: literal-sdk + name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' + - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true diff --git a/snapshots/session/ptc-workspace-context/cordis.yml b/snapshots/session/ptc-workspace-context/cordis.yml index ac55e8c923..d8aa490ecc 100644 --- a/snapshots/session/ptc-workspace-context/cordis.yml +++ b/snapshots/session/ptc-workspace-context/cordis.yml @@ -1,5 +1,9 @@ # PTC mode agent-instructions snapshot recording overlay. The default filesystem # tools trigger nested instruction discovery after a read. +- insert: + - id: literal-sdk + name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' + - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' config: From 5a673b8a59b3b0eb7962587019f37998ec032133 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:28:47 +0800 Subject: [PATCH 3/7] refactor(ptc): share the prompt section return type --- packages/core/tools/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index b4d1a2a805..ccfb594774 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -844,7 +844,7 @@ export class ToolRuntime extends Service { * `both` renders empty: native calls do execute there, so the rule is false. * @returns the section registration. */ - private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + private collapseSection(): PromptSection { return { name: 'tools:ptc-only', order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'), From e1f09116187d64cc1b800f838d4d3a8db53a6250 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:29:01 +0800 Subject: [PATCH 4/7] test(ptc): strengthen literal prompt assertions --- packages/core/system-prompt/tests/system-prompt.spec.ts | 9 +++++++-- packages/core/tools/tests/ptc.spec.ts | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 3294c3179d..1d73203062 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -579,13 +579,18 @@ describe('SystemPrompt', () => { expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`) }) - it.each([false, true])('preserves literal section text with complete=%s', async (complete) => { + it.each([ + [false, false], + [false, true], + [true, false], + [true, true], + ])('preserves literal section text with complete=%s and dynamic=%s', async (complete, dynamic) => { const ctx = new Context() try { await ctx.plugin(SystemPrompt, { includeHarnessIdentity: false, personaPrefix: '{{model}}' }) ctx.systemPrompt.variable('model', () => 'actual-model') const text = '{{item}} {{model}} {{ model }} {{nested{{item}}}}' - ctx.systemPrompt.section({ name: 'literal', order: 1, text: () => text, interpolate: false, complete }) + ctx.systemPrompt.section({ name: 'literal', order: 1, text: dynamic ? () => text : text, interpolate: false, complete }) expect(renderPrompt(await ctx.systemPrompt.assemble())) .toBe(complete ? text : `actual-model\n\n${text}`) } finally { diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index cfb651283e..2ebd121f0c 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -138,9 +138,11 @@ describe('mode-aware wire contribution', () => { execute: args => Promise.resolve(args.value), })) const assembly = await systemPrompt.assemble() + const sdk = assembly.sections.find(section => section.name === 'tools:sdk') + expect(sdk).toBeDefined() const prompt = renderPrompt(assembly) expect(prompt).toContain(description) - expect(prompt).toContain(assembly.sections.find(section => section.name === 'tools:sdk')?.text) + expect(prompt).toContain(sdk!.text) expect(prompt).not.toContain('actual-model') } finally { await ctx.fiber.dispose() From eb9da8930e2752e4d4bc8927be03c9386da83fca Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Fri, 11 Sep 2026 14:34:17 +0800 Subject: [PATCH 5/7] test(boot): cover Web failure recovery and inject module routes --- ...onsumer-owned-startup-strictness.i18n.yaml | 4 +- ...09-09-consumer-owned-startup-strictness.md | 4 + ...09-consumer-owned-startup-strictness.zh.md | 4 + apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 + apps/cli/README.zh.md | 2 + .../tests/web-failure-matrix.expected.e2e.ts | 404 ++++++++++++++++++ packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 + packages/client/modules/README.zh.md | 2 + packages/client/modules/src/index.ts | 4 +- .../modules/tests/node-half.client.spec.ts | 58 ++- 12 files changed, 477 insertions(+), 17 deletions(-) create mode 100644 apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml index e3d96b6365..1b6639b303 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-09-consumer-owned-startup-strictness.md -2026-09-09-consumer-owned-startup-strictness.md: 8e3d5f0141245ba2fa2c85faba607ac1be952e37 -2026-09-09-consumer-owned-startup-strictness.zh.md: 3873b9ceeb6204939e817a53514f5e18767ececc +2026-09-09-consumer-owned-startup-strictness.md: e009e66ead25ef0a5e6001d33663e32bc04d19d2 +2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md index 8e3d5f0141..e009e66ead 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md @@ -33,3 +33,7 @@ Stable required entry ids are part of application assembly. Renaming one require ## Testing App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate. + +The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together. + +The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing. diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md index 3873b9ceeb..58c364056f 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md @@ -33,3 +33,7 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles ## 测试 App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。 + +[Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。 + +矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cfded686cf..0b46922994 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 2e74ef68cf0b8487083a2e2af0f5175c78ec5212 -README.zh.md: 553f28f3af78b18658701449678de80b086dbb15 +README.md: f5566281ab8164cf8e88c424ad5c41a22afe4444 +README.zh.md: b2cae164c639157dc4ea3fa8c83a2b52fce57707 diff --git a/apps/cli/README.md b/apps/cli/README.md index 2e74ef68cf..f5566281ab 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -54,3 +54,5 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f ## Development Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh ` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. + +The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 553f28f3af..b2cae164c6 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -54,3 +54,5 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 ## 开发 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh ` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。 + +[Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。 diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts new file mode 100644 index 0000000000..d2d14a5ada --- /dev/null +++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts @@ -0,0 +1,404 @@ +/** Failure policy through the built Web process and native configuration watcher. */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { createServer } from 'node:http' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' +import { describe, expect, it } from 'vitest' +import { FiberState } from '@deepseek-ai/cordis' + +const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) +const bin = join(repoRoot, 'apps/cli/lib/bin.js') +const built = existsSync(bin) && existsSync(join(repoRoot, 'apps/web/dist/index.html')) +const failures = [ + ['import', 'missing.mjs'], + ['module evaluation', 'matrix module evaluation'], + ['schema', 'matrix schema failure'], + ['config expression', 'matrix config expression'], + ['disabled expression', 'matrix disabled expression'], + ['sync apply', 'matrix sync apply'], + ['async apply', 'matrix async apply'], + ['dependency', 'matrixMissingService'], +] as const +type Failure = typeof failures[number][0] + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-failure-matrix-')) + const home = join(root, 'home') + mkdirSync(home) + const events = join(root, 'events') + const diagnostics = join(root, 'diagnostics') + const serverUrl = join(root, 'server-url') + const states = join(root, 'states') + const stop = join(root, 'stop') + const patch = join(home, 'cordis.patch.yml') + const watcher = join(root, 'watcher.patch.yml') + // Native delivery remains real; completed writes bypass Chokidar's 50 ms change suppression. + writeFileSync(watcher, JSON.stringify([{ id: 'hmr', disabled: false, config: { + root: [], awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + } }]) + '\n') + writeFileSync(events, '') + writeFileSync(diagnostics, '') + writeFileSync(states, '{}') + const observerPath = join(root, 'observer.mjs') + // HMR reports through Cordis logger exporters; WARN is above their default INFO threshold. + writeFileSync(observerPath, [ + "import { appendFileSync, writeFileSync, renameSync, rmSync } from 'node:fs'", + "import { inspect } from 'node:util'", + 'export function apply(ctx, config) {', + ' const report = message => appendFileSync(config.path, message.args.map(arg => inspect(arg)).join(" ") + "\\n")', + ' ctx.logger.buffer.forEach(report)', + ' ctx.logger.exporter({ levels: { default: 3 }, export: report })', + ' const timer = setInterval(() => {', + ' const loader = ctx.get("loader")', + ' if (!loader) return', + ' writeFileSync(config.states + ".tmp", JSON.stringify(Object.fromEntries([...loader.entries()].map(entry => [entry.options.id, entry.fiber?.state]))))', + ' renameSync(config.states + ".tmp", config.states)', + ' }, 20)', + ' ctx.effect(() => () => { clearInterval(timer) })', + ' ctx.inject(["webServer", "connection"], scope => {', + ' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))', + ' scope.effect(() => () => { rmSync(config.url, { force: true }) })', + ' })', + '}', + '', + ].join('\n')) + const observer = { id: 'matrix-log-observer', name: pathToFileURL(observerPath).href, config: { path: diagnostics, url: serverUrl, states } } + const plugin = join(root, 'probe.mjs') + writeFileSync(plugin, [ + "import { appendFileSync, existsSync } from 'node:fs'", + 'export const Config = { "~standard": { version: 1, vendor: "matrix", validate(value) {', + ' return value.mode === "schema" ? { issues: [{ message: "matrix schema failure" }] } : { value }', + '} } }', + 'export const apply = (ctx, config) => {', + ' if (config.mode === "sync apply") throw new Error("matrix sync apply")', + ' return activate(ctx, config)', + '}', + 'async function activate(ctx, config) {', + ' if (config.mode === "async apply") { await Promise.resolve(); throw new Error("matrix async apply") }', + ' if (config.provider) ctx.provide(config.provider, true)', + ' if (config.mode === "detached") setImmediate(() => { void Promise.reject(new Error("matrix detached failure")) })', + ' appendFileSync(config.events, `${config.label} apply ${config.generation}\\n`)', + ' const timer = setInterval(() => { if (existsSync(config.stop)) process.emit("SIGTERM") }, 20)', + ' ctx.effect(() => () => { clearInterval(timer); appendFileSync(config.events, `${config.label} dispose ${config.generation}\\n`) })', + '}', + '', + ].join('\n')) + writeFileSync(join(root, 'evaluation.mjs'), 'throw new Error("matrix module evaluation")\n') + const url = pathToFileURL(plugin).href + const config = (label: string, generation: number, mode = '') => ({ label, generation, mode, events, stop }) + const witness = (generation: number) => ({ id: 'matrix-witness', name: url, config: config('witness', generation) }) + const target = (id: string, failure?: Failure, generation = 1) => ({ + id, + name: failure === 'import' ? pathToFileURL(join(root, 'missing.mjs')).href + : failure === 'module evaluation' ? pathToFileURL(join(root, 'evaluation.mjs')).href : url, + ...(failure === 'dependency' ? { inject: ['matrixMissingService'] } : {}), + config: config('target', generation, failure), + }) + const render = (id: string, failure?: Failure, generation = 1) => { + let text = `- insert: ${JSON.stringify([observer, witness(generation), target(id, failure, generation)])}` + if (failure === 'config expression') text += `\n- id: ${id}\n config: !!js "(() => { throw new Error('matrix config expression') })()"\n` + if (failure === 'disabled expression') text += `\n- id: ${id}\n disabled: !!js "(() => { throw new Error('matrix disabled expression') })()"\n` + return text + '\n' + } + writeFileSync(patch, JSON.stringify([{ insert: [observer, witness(0)] }]) + '\n') + return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target } +} + +function start(f: ReturnType, extra: string[] = []) { + const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], { + cwd: f.root, + env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' }, + input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL', + }) + let stdout = '' + let stderr = '' + child.stdout?.setEncoding('utf8').on('data', (text: string) => { stdout += text }) + child.stderr?.setEncoding('utf8').on('data', (text: string) => { stderr += text }) + let exited = false + void child.then(() => { exited = true }) + async function wait(predicate: () => boolean) { + try { + await expect.poll(() => { + if (exited) throw new Error('Web process exited') + return predicate() + }, { timeout: 45_000 }).toBe(true) + } catch (cause) { throw new Error(`Web condition failed\n${stdout}\n${stderr}\n${readFileSync(f.diagnostics, 'utf8')}\n${readFileSync(f.events, 'utf8')}`, { cause }) } + } + async function serves(currentServer = false) { + await wait(() => /dsh web: http:\/\//u.test(stdout)) + const url = currentServer ? readFileSync(f.serverUrl, 'utf8') : /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1] + if (!url) throw new Error('Missing Web URL') + const auth = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(10_000) }) + const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0] + if (!cookie) throw new Error('Missing Web authentication cookie') + const response = await fetch(new URL('/', url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) }) + expect(response.status).toBe(200) + const html = await response.text() + expect(html).toContain('__DSH_BOOT__') + const bundlePath = /