Skip to content

[PR] fix(dsh): prevent the latest system prompt in history from being collapsed | fix(dsh): 让最新的历史内系统提示不被折叠 - #6353

Open
BBleae wants to merge 3 commits into
ranxianglei:masterfrom
BBleae:2026-11-12_dsh-durable-system-prompt
Open

BBleae wants to merge 3 commits into
ranxianglei:masterfrom
BBleae:2026-11-12_dsh-durable-system-prompt

Conversation

@BBleae

@BBleae BBleae commented Nov 12, 2026

Copy link
Copy Markdown

Fixes #6352

问题

在 dsh 车道上,如果 compress 的范围覆盖了最新的历史内系统提示(in-history system prompt),这条提示会被折叠(fold)进摘要(summary)。从那时起,直到提示文本再次变化,模型一直按过期的头部提示运行。没有报错,也没有日志。

这条消息为什么重要:渲染出的系统提示变化时,DeepSeek Harness 并不总是重写第一个 system 节点。在模型把后面的 system 消息当作有效提示的路由上(systemPromptUpdate: "in-history"),它会在缓存历史之后追加一条完整的新 system 消息。顶层的 system 字段保留旧文本,最新的历史内 system 消息才是正在生效的提示。

billion-context 把这条消息当作普通历史。hasUnrecoverableMediaPayload 只固定结构化的 system 消息("Plain-text ones … fold like any text message",纯文本的……和普通文本消息一样折叠),而 dsh 的持久(durable)分类器(src/durable-message-guards.ts)认识四种载体(carrier),都在 user 通道上。所以模型发起的 compress 调用和代理(proxy)发起的折叠都可能覆盖到它。

这个 PR 恢复了什么,没恢复什么

改动之后,最新提示的文本总会留在转发的请求里。它的角色取决于 wire:

这个形态从哪里来(deepseek-harness 源码,MIT,标签 dsh-v0.2.2-rc.2;与 rc.1 相比没有变化)

  • packages/core/agent-loop/src/runtime-context.ts,SystemPromptProjection.project:"A capable continuing series appends changed nonempty text after the cached history."(一个有能力的连续序列会把变化后的非空文本追加在缓存历史之后。)只有在不支持的路由、提示被清空、或者文本不同的新请求序列这三种情况下,才会重写第一个节点。
  • packages/llm/llm-deepseek/src/serialize.ts:在历史内路由上,非开头的 system 消息会变成 { role: "system", content: [{ type: "text", text }] },排在 user / tool-result 轮之后、下一条 assistant 之前;只有位于开头的 system 才会成为顶层的 system。它的 tests/adapter.spec.ts 固定了结果:提示变化后,body.system 仍是 first prompt,messages 里带着 { role: "system", content: [{ type: "text", text: "second prompt" }] };清空再恢复之后,请求里唯一的提示就是那条历史内消息。
  • packages/llm/llm-pi-ai/src/models.ts(conversationUpdates)和 context.ts:对声明了 supportsMidConvoSystemMessages 的目录模型,同样是历史内模式。它的 tests/conversation-updates.spec.ts 固定了各 wire 的形态:Anthropic Messages 和 chat-completions 用 role: "system",responses API 用 role: "developer" 加字符串内容。
  • dsh 核心里产生 system / developer 消息的只有两处:SystemPromptProjection(system)和 agent.ts 里的工具注册表更新(developer;只有 tool-addition / tool-removal 块,没有文本)。

复现

tests/dsh-system-prompt-durable.test.ts,本 PR 新增。在 master c26c31ac 上它的 14 个测试里有 9 个失败(新导出被桩成返回 undefined,以便文件能加载);通过的 5 个是分类器的反例,以及对必须保持不变的行为的固定。

  • 编解码层:一个 llm-deepseek 形态的 Messages 请求体(头部提示在顶层字段,两次历史内更新,一条 tool-delta system,一个实时请求),经 anthropicToCore 和 applyCompression,使用 dsh 车道配置。在 master 上,最新提示的 id 落在新建的块里面。
  • 真实折叠:在 dsh 车道上对 startServer 跑八轮,两次历史内提示更新,经 /__bili/plugin/tool 做一次范围覆盖这两条的 compress,再跑一轮。在 master 上,转发给上游的请求里不再有最新的提示,Messages wire 和 chat-completions wire 都是如此。

改动

在 dsh 车道分类器里增加第五种持久身份:durable/system-prompt(dshSystemPromptPath),归现有的 durable/* 按身份保留最新一份的默认规则管。最新的一份被固定;被后来的副本顶替的(superseded)副本和普通历史一样折叠。

判定条件:role === "system",内容是文本,非空,并且

  • 不是结构化的 system。这里复用 hasUnrecoverableMediaPayload 而不是重新写一遍,所以这条规则和内核对 tool-delta / 仅指令 system 的硬固定在构造上就是不相交的,在提示之后到达的 tool delta 也不可能占掉提示的最新位置;
  • 不是 bili 自己的摘要载体(渲染角色为 system,KDD feat: bili CLI + debug in config + README rewrite #7)。

分类器按角色匹配,而不是按文本标记,因为系统提示没有稳定的标记:它是任意的宿主和插件文字。这里角色是比标记更强的证据。它由宿主的序列化器打上;人的输入总是走 user 通道,模型输出总是走 assistant 通道,所以用户消息和模型回声(#2766)都产生不出它。头部提示在每个编解码器里都会被提到折叠空间之外,因此 dsh 车道里 system 角色的核心消息就是历史内的那种。

新身份在那十二个标记分类器之前被查询。它们的角色门只拒绝 assistant,所以在 master 上,引用了某个载体标记的系统提示(比如一份讲解 skill 目录标签的提示)会被投影成那个载体的身份,并且因为更新,从真正的 user 通道载体手里抢走最新位置。由角色决定的身份排在前面之后,这条提示保有自己的固定,真正的载体也保有自己的位置(测试:"the four durable carriers keep their own latest slot next to the prompt",在 master 上为红)。

行为变化,仅限 dsh 车道(旧 → 新)

消息 master 本 PR
最新的纯文本历史内 system 可折叠 固定(保留一份)
被顶替的纯文本历史内 system 可折叠 可折叠
文本里含有载体标记的纯文本 system 取得那个载体的身份 durable/system-prompt
结构化 system(tool delta、仅指令) 内核硬固定 内核硬固定,不进分类
那十二个 user 通道载体、assistant 回声 与现在相同 与现在相同
Rule-3 最后一条 user 锚点 最新的人类请求 相同(system 消息从来不是候选)

不变的部分

  • 其他车道:没有分类器,纯文本的历史内 system(Claude Code 的环境说明等)仍会折叠。两种 wire 上都有测试固定。
  • oneShotCarrierPaths / dshCarrierRawMarkers(预期重基线的核算):没有改动;被顶替的提示副本的核算与以前完全一致。那条链仍然没有 system 角色这一步,所以含有载体标记的 system 角色文本,在位置链里算提示,在漂移核算里算那个标记的载体。这是已有的漂移处理方式,源码里有注明,测试文件里也有固定。
  • 整个身份被接管:用户如果配置过任何 durable/… 模式,仍然没有默认值;durable/system-prompt 可以像另外四个一样被点名(已测试)。
  • 两种压缩模式:分类器喂给 Config.messagePathOf,内核在每个判定可折叠性的地方都会查它(compress、unit-projection、recommend、absorb),所以模型发起的折叠和代理发起的折叠由同一条规则覆盖。没有改动任何 wire、工具注入或摘要载体的代码。

按 wire 的适用范围

规则作用在内核核心的角色上,所以与 wire 无关。各 wire 的证据:

配置面

没有新字段、环境变量或 CLI 标志。只在现有的 durable/ 虚拟路径命名空间里多了一个名字,可通过现有的 protectedTools / protectedLatestTools 模式访问。src/config.ts 里 protectedLatestTools 上方的注释列出了这个身份;gen-config-docs generate 没有产生漂移。

内核

kernel/** 下没有改动。分类器导入了两个现有的内核导出(hasUnrecoverableMediaPayload、SUMMARY_HEADER)。

现场证据

一个长期运行的 dsh 会话,九天窗口,只统计结构性数量:143 份历史内系统提示副本;其中 18 到 37 份(两种计数方法分别取交集和并集)在仍是最新副本时被一个新建的块覆盖;按交集计数,这些时段跨越 6,206 个模型请求中的 2,919 个。

相关

未覆盖的部分

  • Messages wire 上留下来的提示的角色(见第二节)。要在那里保持 system 角色,需要改内核(迁移位置,就像 [PR] EN | kernel: relocate misplaced tool-delta systems instead of demoting them (fixes #4818) #5800 对 tool-delta system 做的那样),我没有尝试。需要的话我可以另开 issue。
  • 不会追溯取消折叠。 如果最新的提示已经被早期版本构建出的块覆盖,它会保持被覆盖:prune 丢弃已覆盖的 id 时不查保护,之后的合并会把已被消耗的块的覆盖范围(range)整个接过来(用一次性探针检查过:旧版本折叠,然后在新配置下渲染并合并;两种情况下提示都保持隐藏)。这样的会话要等 dsh 发出下一次提示更新才能恢复。
  • token 成本。 最新的副本永远不会被折叠,所以它的大小永远不在模型发起或代理发起的折叠能回收的部分里。作为参照,Non-leading system messages are demoted to regular history messages, causing the system prompt to accumulate progressive | 非首位的 system message 被降级成普通历史消息,导致 system prompt 逐步累积 #6193 报告过一份 45,463 个字符的提示。我没有检查紧急截断的路径。
  • 带文本的 developer 消息。 dsh 核心没有这样的发出者(它的工具注册表更新只带工具块)。如果某个插件发出了一条,只要序列化器把文本作为单独的纯文本项发出,它就会取得提示身份:llm-pi-ai 在 chat-completions 和 responses 上就是这样做的,即使该消息同时带有工具块(它的 spec:{ role: "system", tools } 后面跟着 { role: "system", content: "…" });在 Messages wire 上只有纯文本的 developer 消息会这样(文本加工具块仍是一条结构化 system)。
  • 漂移核算:宿主在重新基线一个请求序列时淘汰的提示副本,核算方式不变,没有测量。

验证

Node 22.23.3,Windows,隔离的家目录,出站网络被阻断(任何一次运行都没有记录到被阻断的请求)。

  • 新文件:14/14。
  • 导入了被改模块的 11 个测试文件:80 个测试,78 通过,2 跳过,0 失败。
  • 第一个提交上更大范围的定向运行(所有引用了 dsh 车道或持久守卫的测试文件,加上历史内 system 的套件,共 85 个文件):1008 个测试,998 通过,6 跳过,4 取消。4 个取消全在 tests/dsh-native.test.ts,该文件在未修改的 master 上同样时序不稳(5 次基线运行里有 2 次取消同样的 4 个;该文件不导入被改模块)。第二个提交只改了注释和新测试文件。
  • npm run typecheck、npm run build:干净。check-unused-exports、check-test-import-graph、config-surface-guard、check-architecture-docs:干净。
  • 本地没跑:完整套件和 e2e 车道(按 AGENTS §7.2 留给 CI)。

On routes whose model reads a later system message as the effective
prompt, dsh does not rewrite the first system node when the rendered
prompt changes: SystemPromptProjection appends a complete system message
after the cached history, and the Messages serializer ships it as a
plain-text role:"system" message after the user turn. The newest such
message is the prompt in force. ACP folded it like any plain-text
message, so one compress range covering it left the model on the stale
head prompt until the text changed again.

Add a fifth durable identity to the dsh lane classifier,
durable/system-prompt (dshSystemPromptPath), under the existing
durable/* latest-per-identity default: the newest copy is pinned,
superseded copies stay foldable. The criterion is the role (no text
marker exists for a prompt): a non-empty plain-text system-role core
that is neither bili's own summary carrier nor a structured system.
Structured systems keep the kernel's hard pin; the classifier reuses
hasUnrecoverableMediaPayload so the two rules cannot overlap and a tool
delta arriving after the prompt cannot take its latest slot.

The identity is consulted before the twelve marker classifiers. Their
role gate only refuses the assistant role, so a system prompt that
quoted a carrier marker used to project to that carrier's identity and
take the latest slot from the real user-channel instance.

Host layer only: no change under kernel/, no config field, no wire or
persistence change. Other lanes keep folding plain-text in-history
systems. oneShotCarrierPaths (drift accounting) is unchanged.
… limits

Follow-up to the durable/system-prompt identity, from review.

Tests:
- Messages wire end-to-end: assert the role the kept prompt is forwarded
  with. Once the fold removes the assistant that followed it, the
  kernel's position heal re-voices a plain system as user, so the text
  survives and the system role does not. Pinned as a known limit; a
  kernel that relocates instead of demoting flips the assertion.
- Lane wiring: drop the "registry keys are exactly [dsh]" pin (another
  lane registering a classifier is not this test's business) and assert
  instead that no other lane projects a plain-text system.
- Drift registry: pin that system-role text containing a carrier marker
  is still accounted by marker there, as before the identity existed.
- The chat-completions scenario cites the llm-pi-ai wire test it mirrors.

Comments only in src/durable-message-guards.ts: where a developer
message carrying text would share the identity (llm-pi-ai splits the
text off on chat-completions and responses), and that the drift chain
has no system-role step.

Devlog: no retroactive unfold for prompts already covered by a block
from an earlier build, the token cost of a never-folded copy, and the
corrected red count on master (9 of 14).
Copilot AI balanced review requested due to automatic review settings November 12, 2026 05:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Copilot was unable to complete this review at the selected tier due to model policy or plan restrictions.

🟡 Changes recommended

A moderate active issue remains in src/durable-message-guards.ts where using msg.text.trim() as a whitespace check can unnecessarily allocate large strings (e.g., ~45K chars) and create avoidable memory churn in a repeatedly-invoked path, so a non-allocating whitespace test is needed.

1 open issue
What changed in this PR

Fix a dsh-lane correctness bug where the latest effective in-history system prompt could be folded into a summary, causing requests to run with a stale head prompt until the prompt text changes again.

Changes:

  • Add a new dsh durable identity durable/system-prompt to preserve the newest plain-text in-history system prompt while keeping superseded copies foldable.
  • Prioritize this identity ahead of marker-based durable carriers to prevent carrier-slot takeover.
  • Add regression coverage for classifier behavior, compression geometry, and real folding on both Messages and chat-completions wires, plus update inline docs/devlogs.
File Description
tests/​dsh-system-prompt-durable.test.ts Adds a new regression test suite validating that the newest in-history system prompt is preserved through classification and real compression for Messages and chat-completions wires.
src/​durable-message-guards.ts Introduces dshSystemPromptPath (durable/system-prompt) and wires it early in the dsh classifier so the latest plain-text system prompt is protected from folding.
src/​config.ts Updates configuration documentation comments to include the new durable/system-prompt identity alongside existing dsh durable carriers.
devlog/​2026-11-12_dsh-durable-system-prompt/​WORKLOG.md Documents the investigation, implementation notes, testing performed, and known limitations for the dsh durable system-prompt change.
devlog/​2026-11-12_dsh-durable-system-prompt/​REQ.md Captures the requirements, acceptance criteria, and explicit non-goals for preserving the latest in-history system prompt in dsh.

Review effort: Balanced

Comment on lines +200 to +205
export function dshSystemPromptPath(msg: CoreMessage): string | undefined {
return msg.role === "system" && msg.contentType === "text" && typeof msg.text === "string" &&
msg.text.trim().length > 0 && !msg.text.startsWith(SUMMARY_HEADER) && !hasUnrecoverableMediaPayload(msg)
? "durable/system-prompt"
: undefined;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已改:换成 /\S/.test(msg.text),不再为判空复制整段文本。

@ranxianglei ranxianglei changed the title fix(dsh): 让最新的历史内系统提示不被折叠 [PR] fix(dsh): 让最新的历史内系统提示不被折叠 Nov 12, 2026
@ranxianglei ranxianglei changed the title [PR] fix(dsh): 让最新的历史内系统提示不被折叠 [PR] fix(dsh): prevent the latest system prompt in history from being collapsed | fix(dsh): 让最新的历史内系统提示不被折叠 Nov 12, 2026
dshSystemPromptPath used msg.text.trim().length > 0 only to ask whether
the text has any non-whitespace character. trim() allocates a copy of
the whole prompt (tens of thousands of characters in the reports that
motivated the identity), and the classifier is consulted repeatedly
during protection and fold checks. /\S/.test(msg.text) answers the same
question (same whitespace set as trim) without the copy.

No behavior change; the existing negatives (empty and whitespace-only
system nodes) cover it. From review on the PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][dsh] compress 可能折叠正在生效的历史内系统提示,模型会一直按过期的头部提示运行,直到提示文本再次变化

2 participants