DeepSeek Harness 会话永久失败:崩溃的 tool_use 让每轮都报 INVALID_REQUEST
DSH 会话「崩溃一次、永久失灵」是一条能被完整解释、也能被完整恢复的链路:一轮在工具调用进行中崩溃,在历史里留下没有结果的 tool_use;而 DeepSeek Messages 序列化器要求「工具调用必须立刻拿到结果」,于是此后每一轮都在请求发出之前就被本地校验拦下,报 DeepSeek Messages tool calls need immediate results(INVALID_REQUEST)。崩溃的根因是 TOOL_RUNTIME_SCHEDULER 用 Symbol() 而不是 Symbol.for() 定义,跨模块图查到了 undefined,一行即可修复;而「日志为什么不能自愈」是第二个独立问题——interruptedTurnClosers 只在日志中途结束时运行,本例里 turn/end 已经写下,日志被视为平衡,修复流程永不触发。已中毒的会话无法靠升级救回,只能修复日志或新开。
先分诊:一次崩溃会留下三层问题
结论先行:把「崩溃」「中毒」「永久失败」当成三层独立的故障来看,你才能判断自己卡在哪一层,以及每一层分别该做什么。 这是本文最重要的一步分诊——它们症状相似(都是「会话用不了」),但处置方式完全不同,混在一起修只会白费力气。
第 1 层,崩溃发生的那一轮:工具调度在第一次调用就抛错,一轮以 error 结束。上报的环境是 DSH 0.1.6-alpha.2、macOS arm64,provider deepseek-official、模型 deepseek-flash、reasoning effort high,工具模式 native。崩溃信息是 Cannot read properties of undefined (reading 'prepare'),错误码 UNKNOWN(#7318)。
第 2 层,日志里留下的断点:模型在一条助手消息里声明了 N 个工具调用,宿主只为第一个写下了 tool/call,然后就死了。剩下那些既没有 tool/call、也没有 tool/result;已经写下 tool/call 的那个也没有 tool/result。它们全部成为悬空的 tool_use。
第 3 层,此后每一轮瞬时失败:序列化器在把历史转成 DeepSeek Messages 时拒绝任何 pending 工具调用,报 DeepSeek Messages tool calls need immediate results,外层包成 INVALID_REQUEST。注意失败发生在发起任何新工具调用之前,所以你换提示词、换模型、换工具都没用。
三层的时间线在日志里是这样长出来的:
assistant/message content: [ …, {type:'tool-call', id:'call_00_…'}, {type:'tool-call', id:'call_01_…'} ]
tool/call { callId: 'call_00_…', name: 'grep' } <-- 只有第一个调用拿到 tool/call
step/end { turn: 6, step: 1 }
turn/end { reason: { kind: 'error', error: { message: "Cannot read properties of undefined (reading 'prepare')", code: 'UNKNOWN' } } }
… 下一条用户消息 …
assistant/attempt finish.reason.failure = { message: "DeepSeek Messages tool calls need immediate results", code: 'INVALID_REQUEST' }
turn/end { reason: { kind: 'error', error: { …INVALID_REQUEST… } } }
要判断你的会话是否属于这一类,按下面四步做:
-
先看这一轮的起始:失败是否发生在「没有任何新
tool/call产生」的情况下?如果是,说明请求在本地就被拦了,问题在历史而不在运行时。 -
再数工具调用的账:取崩溃前最后一条
assistant/message,数其中type: 'tool-call'的块数;再数整条日志的tool/call与tool/result条数。 -
对比差值:块数 −
tool/result数,就是悬空tool_use的条数。上报的三份样本如下表(#7318)。会话 tool/calltool/result崩溃消息里的 tool-call 块 悬空 tool_usesession-69a110fd389 388 2 2 session-c607c71a1 0 2 2 session-40158bc71 0 1 1 -
确认它与工具无关:三份样本分别崩在
grep、pwsh、subagent三个完全不同的工具上,而且都是该轮的第一个工具调用;session-69a110fd在崩溃前 5 轮里 388 次调用全部正常。这三点合起来说明:问题不是某个工具坏了、也不是某个配置错了,而是随时间变化的环境性故障——这正是下一节那个模块身份问题的典型特征。
根因与一行修复:Symbol() 的模块实例身份
根因已确认:TOOL_RUNTIME_SCHEDULER 用 Symbol() 定义,它的身份是「按模块实例」的;当 dsh-tools 这个包同时落在两个 bundle / 两份模块图里,注册用一个 symbol、查找用另一个,查到的就是 undefined,于是第一个工具调用抛 reading 'prepare'。把 Symbol() 换成 Symbol.for() 即可修复,一行改动。
崩溃点的读取位置在 native 路径上是 packages/core/agent-loop/src/tool-calls.ts:170:
callSeqs[index] = appendToolCall(session, turn, step, call.block) // :168 durable tool/call 已写入
started++ // :169
const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec) // :170 这一行抛错
注意 :168 已经先把 tool/call 落进了持久日志,:169 把 started 加了 1,然后 :170 才抛错。这就是为什么悬空的 tool_use 一定会被写进日志——崩溃发生在「记账之后、执行之前」。同样的符号查找也出现在 packages/core/tools/src/ptc.ts:615,两条调度路径共享同一个根因(#7318)。
定义的原始形态在 packages/core/tools/src/index.ts:463:
export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
修复只有一行(社区 fork 的单提交 4dcf2d1):
export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol.for('@deepseek-ai/dsh-tools.scheduler')
为什么这一行就够:Symbol() 每次求值都产生全新的、绝不相等的身份,而 Symbol.for(key) 走全局符号注册表,同一个 key 在任何模块实例、任何 bundle 里都解析到同一个 symbol。这样一来,注册方与查找方用到的键必然一致,registry[TOOL_RUNTIME_SCHEDULER] 不再可能是 undefined(fork diff)。
这也不是社区发明的写法——仓库里其它跨包符号(例如 dsh.subagent.*、dsh.typert.owned-value)本来就使用 Symbol.for() 这一约定,只有这一处漏掉了。上报表述很谨慎:最初报告者只是「顺手提一句」,怀疑它可能是自建副本的产物;随后维护者在 Linux 源码构建上复现了同样的失败,才把根因坐实。
如果你要自己验证修复效果,可以按下面四步做:
- 在源码 checkout 里把
index.ts:463的Symbol(改成Symbol.for(,保留参数不变。 - 重新构建(社区实测环境为 macOS arm64、
0.1.6-alpha.2源码构建)。 - 开一条新会话,让它执行一个会触发工具调用的请求,确认第一次调用不再抛
reading 'prepare'。 - 注意:这一步只证明「不再产生新的中毒会话」。对已经中毒的旧会话无效——那是下一个问题。
如果你不打算改源码,那么能做的至少是:不要再指望升级能救回中毒会话。有报告把构建回滚到 0.1.6-alpha.1 之后,该会话在第 3 轮仍然失败;构建回滚和新构建都不能改变已写入日志的事实(#7318)。
为什么日志不能自愈:interruptedTurnClosers 的触发条件缺口
第二个独立问题:DSH 本来有一个「会话中断自修复」机制 interruptedTurnClosers,会给未完成的工具调用补上合成的结果事件;但它的触发条件是「日志在同一轮内戛然而止」。本例里 turn/end 已经带着 error reason 写下了,日志在结构上算平衡,于是修复永远不运行——而助手消息里仍然挂着未解析的调用,没人清理。
先看「失败路径为什么写不出合成结果」。调度失败的处理在 tool-calls.ts 里是这样组织的(#7318):
// 调度失败时
catch (error) {
// …
throw error; // :235 直接 rethrow
}
// 唯一的合成结果恢复路径(永远不会被执行到)
if (aborted) {
appendSkippedToolCall(…) // :238
}
两个缺口叠加:
- catch 在
:235直接 rethrow,appendSkippedToolCall那条分支永远够不到; - 即便够到,循环也是从
group.slice(started)开始,会跳过那条已经在:168追加过tool/call的调用。
再看「持久层为什么不管」。packages/core/session/src/repair.ts 的 interruptedTurnClosers 只在日志中途结束时追加合成的 TOOL_NOT_STARTED / TOOL_OUTCOME_UNKNOWN。本例的日志是:
tool/call { callId: 'call_00_…' } ← 已写入
step/end { turn: 6, step: 1 } ← step 收尾了
turn/end { reason: { kind: 'error', … } } ← 这一轮也收尾了(带 error reason)
有 turn/end ⇒ 这一轮「闭合」⇒ 日志被视为 balanced ⇒ repair 永不运行。这就是「从应用视角看会话永久不可恢复」的直接原因:不是没有修复能力,而是修复能力的触发条件比故障的形态窄(#7318)。
社区给出的方向是一致的:把崩溃修复这一遍扩展到「带 pending 调用、以 error 结束的轮次」即可。支撑这个方向的证据是——有人手工把 interruptedTurnClosers 本该写的事件补进去后,v3 格式校验器和运行时不变式检查都接受了,会话随即恢复正常。也就是说,缺的只是「谁来触发」,而不是「这些事件的合法性」。
手工恢复中毒会话:四步,含 zstd 分帧
在官方修复落地之前,已中毒的会话是可以手工救回的:停进程 → 备份 → 解压并给每个未解析的 tool-call 插入一条合成 tool/result → 按「header 行自成第一个 zstd 帧」的规则重新压缩。 下面这套步骤来自社区实际恢复记录,本质是「手工重放 interruptedTurnClosers 本会写入的内容」。
第 1 步,停掉所有持有会话锁的 dsh 进程。会话日志有锁,进程活着的时候改文件既可能被覆盖、也可能损坏。
第 2 步,备份原始日志。文件在 ~/.dsh/sessions/<workspace-key>/<session-id>/session.v3.jsonl.zstd:
cp ~/.dsh/sessions/<workspace-key>/<session-id>/session.v3.jsonl.zstd \
~/.dsh/sessions/<workspace-key>/<session-id>/session.v3.jsonl.zstd.bak
第 3 步,解压并插入合成结果:
zstd -d session.v3.jsonl.zstd -o log.jsonl
然后,对最后一条 assistant/message 里每一个「没有匹配 tool/result」的工具调用块,处理规则如下:
- 在它的
tool/call事件之后插入合成tool/result;如果这个调用从未启动(没有tool/call),则插入在助手消息之后。 - 合成事件使用当前 open step 的
turn/step,surfaceOp: "append",seq顺延,并把后续事件重新编号。 - 已启动的调用:
error: { name: "ToolOutcomeUnknownError", code: "TOOL_OUTCOME_UNKNOWN" },并带sourceEventSeqs: [<tool/call 的 seq>]。 - 从未启动的调用:
error: { name: "ToolNotStartedError", code: "TOOL_NOT_STARTED" },message id 必须是interrupted-tool-result-<callId>-<seq>——这是 v3 格式校验器认可的规范修复身份。 - 消息内容放一个
tool-result块,isError: true,说明文字沿用repair.ts里的原话,保持一致。
第 4 步,重新压缩时,header 行必须自成第一个 zstd 帧。持久层会断言 first frame is not exactly one header line,所以不能把整份 JSONL 一次性压成一个帧,否则在它看来文件从头就是坏的(#7318):
head -1 log.jsonl | zstd > out.zst && tail -n +2 log.jsonl | zstd >> out.zst
做完这四步,会话即可恢复,新的轮次能正常完成。
必须强调两点:这是对持久日志的外科手术,不是受支持的路径;同时它也顺带证明了修复事件的合法性——v3 校验器与运行时不变式检查都接受这些合成事件,所以「把崩溃修复扩展到 error 结束的轮次」应当是一个安全的修复方向。如果你不确定自己改的是否正确,更稳妥的选择是:把关键上下文复制到一条新会话里继续,而不是冒损坏日志的风险。
排查与预防注意事项
- 别把「模型报错」和「本地序列化拦住」混为一谈。
DeepSeek Messages tool calls need immediate results不是模型返回的,它在serialize.ts:117/:121抛出,请求根本没发出去。判断方法:失败那一轮没有任何新的tool/call产生。 - 别指望换模型、换 provider、换工具能绕过。 历史是会话级的,校验发生在请求构造阶段,与模型无关。
- 别指望升级或回滚能救回中毒会话。 已在日志里的悬空
tool_use不会被新构建清理;session-40158bc7回滚到0.1.6-alpha.1后第 3 轮仍然失败就是直接证据。 - 判断「是不是这个故障」,最快的一步是数账。 崩溃前最后一条
assistant/message里的tool-call块数,减去tool/result数,差值大于 0 就是。 - 一行修复只解决「新崩溃」,不解决「旧中毒」。 改
Symbol.for()之后不再产生新的悬空调用;但修复路径的缺口(interruptedTurnClosers的触发条件、appendSkippedToolCall够不到)是另一个独立问题,需要另修。 - 预防性的操作习惯:崩溃一旦发生,先不要继续往这条会话里发消息。多发一条消息只是在同一个坏历史上反复失败,还会在日志里堆积更多
INVALID_REQUEST的assistant/attempt,让后面的手工修复更难辨认「最后一条正常消息」在哪。正确的顺序是:先确认崩溃、再决定是修日志还是复制上下文开新会话。
来源:
- #7318 — A crashed tool call leaves an unresolved tool_use in history; the session then fails forever with "DeepSeek Messages tool calls need immediate results"
- #7265 — 工具调度
reading 'prepare'崩溃(同源上下文) - sheldonzhang312-hub/deepseek-harness · fix/tool-runtime-scheduler-symbol(diff 4dcf2d1)
会话被一条悬空调用锁死的时候,最烦的不是修日志本身,而是你连「现在到底坏在哪一层」都要先猜半天。上面这些步骤能救回数据,但如果你还想顺手把环境里其它插件的重复坑一并排掉,可以装一个 DSH Plugin Hub——DeepSeek Harness 桌面端内置的官方插件市场,用来浏览、安装、卸载和更新插件,自带更新检测、系统诊断与系统日志面板,通知中心会集中记录安装/卸载/更新的历史、进行中任务的进度和待重启提醒:

排查这类会话级故障时,把插件生态的变动和宿主日志放在一起看会省很多事。
常见问题
因为坏掉的是这条会话的**持久日志**,不是运行时。崩溃那一轮在历史里留下了没有结果的 tool_use,而序列化器在把历史转成 DeepSeek Messages 时,遇到任何未完成的工具调用都会直接抛错,并且要求历史不能以未解析的工具结尾。于是从那一刻起,这条会话的每一轮都在「真正调用模型之前」就失败,与模型、网络、你的新提示词都无关。新会话历史是干净的,自然正常。
不是模型返回的,也不是网络错误。它是 DSH 自己的 DeepSeek Messages 协议序列化器抛的:packages/llm/llm-deepseek/src/protocols/messages/serialize.ts:117 在序列化到任何 pending 的工具调用时抛错,:121 在历史以未解析的工具结尾时抛错。外层再把它包成 INVALID_REQUEST 写进 assistant/attempt 的 finish.reason.failure。换句话说,这是「本地历史自校验没通过」,请求根本没发出去。
不能。已中毒的会话把故障**固化在日志里**了:有报告显示,把构建回滚到 0.1.6-alpha.1 之后,该会话在第 3 轮仍然失败。新构建只能修掉「再次崩溃」的可能,救不了已经写进日志的那条悬空 tool_use。要恢复只能按本文第四节对日志做一次修复(或直接新开会话)。
不用解压也可以先做一次粗判:在会话目录里找到 session.v3.jsonl.zstd,用 zstd -d 解出 JSONL 后,统计最后一条 assistant/message 里 type: 'tool-call' 的块数,与 tool/call 和 tool/result 事件的条数对比。典型形状是:模型一条消息里发出 2 个 tool-call,但只有第 1 个写下了 tool/call,且没有任何 tool/result —— 于是 2 条未解析。三份上报样本分别是 389/388/2、1/0/2、1/0/1(tool/call 数 / tool/result 数 / 崩溃消息里的 tool-call 块数)。
不能,它修的是**崩溃**而不是**修复路径**。把 TOOL_RUNTIME_SCHEDULER 从 Symbol() 改成 Symbol.for(),可以让「一轮的第一个工具调用不再抛 reading 'prepare'」,即不再产生新的中毒会话;但已经写进日志的悬空 tool_use 仍然没人清理,interruptedTurnClosers 也不会被触发。这一点在社区讨论里被明确区分为「两个独立问题」。
相关术语
- 未解析的 tool_use
- 助手的某条 `assistant/message` 里声明了一个工具调用块(`type: 'tool-call'`),但对应的事件流里再也找不到匹配的 `tool/result`。DeepSeek Messages 协议要求工具调用必须紧跟结果,因此一条悬空的 `tool_use` 会让整条历史无法序列化——它不是「模型犯错」,而是「持久日志出现了不会自愈的断点」。— https://github.com/deepseek-ai/deepseek-harness/discussions/7318
- TOOL_RUNTIME_SCHEDULER
- 工具运行时调度器在注册表里的键,定义于 `packages/core/tools/src/index.ts:463`。它原本写成 `Symbol('@deepseek-ai/dsh-tools.scheduler')`:`Symbol()` 的身份是**按模块实例**的,同一个包被加载成两份(两个 bundle / 两份模块图)时,注册用的是一个 symbol、查找用的是另一个,于是查到 `undefined`,第一次工具调用就抛 `Cannot read properties of undefined (reading 'prepare')`。— https://github.com/sheldonzhang312-hub/deepseek-harness/commit/4dcf2d1
- interruptedTurnClosers
- `packages/core/session/src/repair.ts` 里的合成收尾逻辑:当会话日志**在中途(同一轮内)戛然而止**时,它会给未完成的工具调用补上合成的结果事件,使日志重新平衡。触发条件是「日志以未闭合的一轮结束」;如果 `turn/end` 已经写下(哪怕带着 error reason),日志就算平衡,这段修复永远不会运行——这正是本问题里那个「自修复缺口」。— https://github.com/deepseek-ai/deepseek-harness/discussions/7318
- TOOL_OUTCOME_UNKNOWN 与 TOOL_NOT_STARTED
- 两个规范化的「工具结果未知」错误码,用于合成收尾。工具**已经启动**(已有 `tool/call`)但结果丢失时用 `ToolOutcomeUnknownError` / `TOOL_OUTCOME_UNKNOWN`;工具**根本没来得及启动**时用 `ToolNotStartedError` / `TOOL_NOT_STARTED`。未启动那条的 message id 必须形如 `interrupted-tool-result-<callId>-<seq>`,这是 v3 格式校验器认可的规范身份。— https://github.com/deepseek-ai/deepseek-harness/discussions/7318
来源
- #7318 — [Bug] A crashed tool call leaves an unresolved tool_use in history; the session then fails forever with "DeepSeek Messages tool calls need immediate results"· deepseek-ai(GitHub Discussions)
- #7265 — 工具调度 `Cannot read properties of undefined (reading 'prepare')` 崩溃(同源上下文)· deepseek-ai(GitHub Discussions)
- sheldonzhang312-hub/deepseek-harness · fix/tool-runtime-scheduler-symbol(单提交一行修复,diff 4dcf2d1)· GitHub(社区 fork)