一条空 id 的 tool call 会让会话永久不可用 #5182
Replies: 6 comments 1 reply
|
源码侧全部核实(本地 0.1.2-alpha.1 = cd5ef81,与你的 rc.2/alpha.2 相关文件一致;alpha.2 仅版本号变动)。你的六步故障链 + 四层修复都能在源码里逐行对上,且这个案例属于一个已知家族——你引用 serialize.ts 里 "a null here bricks every later turn" 的注释正是同一家族此前修过的变体(content 为 null),空 id 是这个家族漏掉的兄弟变体。 核实结果(逐条):
四层修复评估:分层合理,且与这个家族的既有修复蓝本一致。几点补充:
通道提示:上游 Issues/PRs 目前禁用,Discussion 就是提交补丁的现实载体;你的 diff 已经完整(+132/−23, 四层独立成立),若上游恢复 PR 通道,这个 patch 可直接作为 PR 提交。 此缺口无插件形态方案——修复点在协议边界(assembler/serialize),属于核心层;插件无法在正确的位置拦截。核心修正是正解。 |
|
不替官方辩护——这个 bug(异常帧写死整个会话)确实还没修。但对你眼下的处境,最有用的建议是:别赌"官方下次一定修",让每个会话随时有可退的副本。 具体做法(我就是这么干的):装 dsh-backup,让它每 12 小时自动把整个 ~/.dsh(所有会话 + 设置 + 技能插件配置)备份一份,断了网、重启电脑都不影响: dsh plugin --profile web add @xiaoyuyu6420/dsh-backup
/backup auto 12真遇到今天这种事,恢复就一句话: 这样最坏情况从"一次偶发就把会话写死、后续全没"变成"最多少一点最近的对话"。备份是最后一层保险,别等到踩雷才想起它。 |
|
很有用,帮我解决了这个问题 |
|
追加一个后续:上帖的四层修复部署后,同一个网关(走集体采购的 kimi-k3 的 api)又触发了一个新变种的 400,暴露出修复里 name 维度的一个缺口。已在本地补上,供参考。 新报错(与上帖不同)故障链模型流了一帧既无 id 也无 name 的 tool call(比上帖的形态更缺一格)。四层修复按设计工作:装配层拒收空 id 的 block-end,partial 保持 open,走 问题出在下一轮重放:出栈过滤的守卫写的是 也就是说:空 id 是「写路径毒化」,空 name 是「重放路径毒化」——后者毒得慢一轮(调度那轮还能自纠错,重放那轮才死),但结局相同:session 永久 400。 修复思路修正:孤儿不该消失,该变得可解析。replay 守卫改为:
一个失败的中间方案值得记录:我最初实现的是「孤儿 result 跟着 call 块一起丢」,15 个测试挂掉——DeepSeek 路径的既有用例证明「无前置 assistant 块的孤儿 tool result」是压缩(compaction)裁掉 assistant 消息后的正常形状,DeepSeek API 容忍它(按 tool_call_id 关联)。所以「无条件丢孤儿」会回归一个合法场景;「占位名」两个家族都兼容。 对 llm-deepseek 路径维持原状(serializeAssistant 丢弃空 name 块 + result 保留):DeepSeek 自家 API 对孤儿 result 是容忍的,没有 Kimi 的 order-match 强约束。 追加:写路径的另一个缺口(晚到的 id)排查这轮时发现入口守卫还有一个与 pi-ai 行为相悖的缺口:pi-ai 对 tool call 的 id/name 有晚填机制( 修复:delta 处理改为从事件的 live partial( 验证
对上帖结论的一点修正上帖说「UNKNOWN_TOOL 是自纠错、非 session 致命」——在 DeepSeek 直连路径上仍然成立,但在 Kimi (以及任何要求 tool 消息强关联的 OpenAI 兼容后端)上只对了一半:自纠错的那一轮本身会写下毒化重放的种子。所以第 3/4 层的守卫条件里, 更新的 patch(累计 8 文件,+230/−23,含本轮 stream.ts 的晚到 id 补救): 本次增量 diff(replay.ts + 回归测试)diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts
@@ -148,12 +148,19 @@ function foreignAssistant(message: Message): AssistantMessage {
case 'text': content.push({ type: 'text', text: block.text }); break
case 'reasoning': content.push({ type: 'thinking', thinking: block.text }); break
case 'tool-call': {
- // Durable transcripts written before the assembler's malformed-block
- // guard can carry an id-less/nameless tool call. Converting it would
- // emit `tool_calls[].id === ""` on the wire — a 400 on every later
- // request. Drop it here so poisoned sessions keep working.
- if (block.id === '' || block.name === '') break
+ // Durable transcripts written before the assembler's malformed-block
+ // guard can carry an id-less tool call. Converting it would emit
+ // `tool_calls[].id === ""` on the wire — a 400 on every later
+ // request. Drop it here so poisoned sessions keep working.
+ if (block.id === '') break
+ // A name-less call (id present, possibly a synthesized `call-N`
+ // fallback from the assembler guard) must stay on the wire: dropping
+ // it orphans the paired tool result, which Kimi-family backends
+ // reject ("tool messages need a resolvable tool name ... or match a
+ // preceding assistant tool_call by order"). Replay it with a
+ // placeholder name — dispatch already answered the original call
+ // with an `unknown tool` error result, so the pairing survives.
content.push({
type: 'toolCall',
id: block.id,
- name: block.name,
+ name: block.name === '' ? 'unknown' : block.name,
arguments: parseArguments(block.arguments),
}); break
}
@@ -206,12 +213,13 @@ function replayedAssistant(message: Message, source: ModelMessageSource, rawStat
case 'tool-call': {
- // Same guard as foreignAssistant: pre-fix transcripts may carry an
- // id-less/nameless tool call; replaying it verbatim would 400 the
- // request. Drop the block (and misalign the replay envelope, which the
- // catch in toPiAssistant degrades to foreign conversion — also guarded).
- if (block.id === '' || block.name === '') return invalidReplay(`block ${index} is a malformed empty-id/name tool call`)
+ // Same guard as foreignAssistant: pre-fix transcripts may carry an
+ // id-less tool call; replaying it verbatim would 400 the request.
+ // An empty name (with a valid id) is NOT dropped — see the
+ // foreignAssistant tool-call case for why the paired result must
+ // keep its matching assistant tool_call on the wire.
+ if (block.id === '') return invalidReplay(`block ${index} is a malformed id-less tool call`)
return {
type: 'toolCall',
id: block.id,
- name: block.name,
+ name: block.name === '' ? 'unknown' : block.name,
arguments: parseArguments(block.arguments),
...replay.type === 'tool-call' && replay.thoughtSignature !== undefined ? { thoughtSignature: replay.thoughtSignature } : {},
}
}diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts
@@ -409,4 +409,44 @@ describe('pi-ai request context conversion', () => {
+ it('replays a synthesized-id nameless call with a substituted name so its paired result stays matched', () => {
+ // Shape from the Kimi-family 400: the assembler guard froze a tool call
+ // as {id: 'call-0', name: ''} and dispatch answered with an unknown-tool
+ // error result. Dropping the call block on replay would orphan the
+ // result ("tool messages need a resolvable tool name ... or match a
+ // preceding assistant tool_call by order"); the call must stay on the
+ // wire with a non-empty placeholder name instead.
+ const fallbackId = CallId('call-0')
+ const context = toPiContext(request([
+ history('assistant', [{ type: 'tool-call', id: fallbackId, name: '', arguments: '{}' }]),
+ user([{
+ type: 'tool-result',
+ toolCallId: fallbackId,
+ content: [{ type: 'text', text: 'Error: unknown tool ""' }],
+ isError: true,
+ }]),
+ ]))
+
+ expect(context.messages).toEqual([
+ {
+ role: 'assistant',
+ content: [{ type: 'toolCall', id: 'call-0', name: 'unknown', arguments: {} }],
+ api: 'dsh-foreign',
+ provider: 'dsh-foreign',
+ model: 'dsh-foreign',
+ usage: expect.anything(),
+ stopReason: 'toolUse',
+ timestamp: 0,
+ },
+ {
+ role: 'toolResult',
+ toolCallId: 'call-0',
+ toolName: 'unknown',
+ content: [{ type: 'text', text: 'Error: unknown tool ""' }],
+ isError: true,
+ timestamp: 0,
+ },
+ ])
+ }) |
|
补一下追加内容的后半段——上一条评论发出去之后,第三个真实会话又把「丢弃策略」的一个隐性代价暴露出来了,入口层的方案因此从「丢弃」升级成了「抢救」。 新故障形态(不是 400,是无限重试)受影响会话在 turn 15/16 陷入循环:模型每步产出 关键证据在 usage:每轮约 112 个 output token,但 delta 一个都没落盘。chunk 日志里只有 即:模型完整地生成了一个工具调用(name + arguments 都有内容),但这个调用全程没有任何一帧带 id。入口守卫把这些 delta 全丢了——防 400 本身没错,但丢的代价远超预期: 守卫防住了 400 毒化,却把「模型在说话」变成了「模型在沉默」——模型每次重试都被再次消音,loop 到 token 上限。 修复:抢救而非丢弃入口层(stream.ts)两处改动:
至此三层语义闭环:id 缺失 → 入口合成 id 抢救 → 正常派发(或 nameless 时装配层兜底 + 重放补名)。上一个故障形态(Kimi 孤儿 400)和这一个(重试循环)共用同一套入口。 顺带修正「晚到的 id」一节里说的快照问题,本次修复同时覆盖:delta 处理改为从事件的 live partial( 验证
累计 patch:8 文件 +271/−24(stream.ts 的 salvage 增量如下): stream.ts 增量 diffdiff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts
@@ toolcall_delta @@
case 'toolcall_delta': {
const known = toolIds.get(event.contentIndex)
+ // Refresh the cached id/name from the event's live partial: pi-ai
+ // late-fills a tool call's id/name once the provider sends them, so
+ // a delta arriving after the fill carries the authoritative identity
+ // (the start-time snapshot would stay stale and drop recoverable
+ // calls' argument deltas).
+ const liveBlock = event.partial.content[event.contentIndex]
+ const liveId = liveBlock?.type === 'toolCall' && liveBlock.id.length > 0 ? liveBlock.id : undefined
+ const liveName = liveBlock?.type === 'toolCall' && liveBlock.name.length > 0 ? liveBlock.name : undefined
+ const id = liveId ?? known?.id ?? ''
+ const name = liveName ?? known?.name ?? ''
+ // Salvage: a call whose id never arrives on any frame still carries
+ // real argument deltas — dropping them silently destroys the model's
+ // output tokens and the model retries forever (observed: 11 retries
+ // x ~112 output tokens each, then "completed response with no
+ // content"). Synthesize a stable per-index id instead: the wire
+ // poison was PERSISTING an empty id, and a non-empty synthesized id
+ // replays safely (Kimi-family resolves by order/name).
+ const effectiveId = id !== '' ? id : `call-synth-${event.contentIndex}`
+ toolIds.set(event.contentIndex, { id: effectiveId, name })
- // An id-less provider frame is the wire-poison shape... Drop the delta.
- if (known === undefined || known.id.length === 0) break
yield {
type: 'tool-call-delta',
index: event.contentIndex,
- id: CallId(known.id),
- ...known.name.length > 0 ? { name: known.name } : {},
+ id: CallId(effectiveId),
+ ...name.length > 0 ? { name } : {},
argumentsDelta: event.delta,
}
} break
@@ toolcall_end @@
case 'toolcall_end': {
- // Same guard at the close: an id-less (or nameless — undispatchable)
- // tool call is dropped rather than emitted.
- if (event.toolCall.id.length === 0 || event.toolCall.name.length === 0) break
+ // Salvage the same id-less shape the delta path salvages: when the
+ // id never arrived on any frame, adopt the delta path's synthesized
+ // `call-synth-${index}` so the closed block matches the streamed
+ // deltas instead of diverging (a diverging close would drop the
+ // accumulated arguments and re-poison via the assembler's hollow
+ // fallback). A nameless close with a real id still cannot be
+ // dispatched; dropping it keeps the partial open for the synthesized
+ // fallback, and replay substitutes a name for the persisted pair.
+ const synthId = `call-synth-${event.contentIndex}`
+ const cached = toolIds.get(event.contentIndex)
+ const closedId = event.toolCall.id.length > 0
+ ? event.toolCall.id
+ : cached !== undefined && cached.id === synthId ? synthId : ''
+ if (closedId === '' || event.toolCall.name.length === 0) break
yield {
type: 'block-end',
index: event.contentIndex,
block: {
type: 'tool-call',
- id: CallId(event.toolCall.id),
+ id: CallId(closedId),
name: event.toolCall.name,
arguments: JSON.stringify(event.toolCall.arguments),
},
}
} break |
|
同意:这是引擎把空
dsh plugin --profile web add "github:xiaoshenming/dsh-session-surgeon#main" |
Uh oh!
There was an error while loading. Please reload this page.
现象
一次正常的长会话写作过程中,会话突然失效。此后无论输入什么,每次 LLM 请求都被模型服务直接以 400 拒绝:
重试无效,新建 turn 也无效。该会话派出的后台子 agent 也终止在同一个错误上,closing message 为空。
事后定位,起因是模型服务的流式响应里出现了一帧异常的
tool_calls:id和function.name均为空(arguments却是完整的 JSON)。这类帧出现的概率很低——出问题的会话里 183 帧 tool-call-delta 中只有这一帧——但一帧就足以写死整个会话。环境
b150a551b8);已对照最新 master0a53fb55be(0.1.2-alpha.2),下列问题均未修复排查
解开会话日志(session.jsonl.zstd),对照 400 报错里的
index[32],可以完整还原故障链:tool_calls没有 id。pi-ai 的ensureToolCallBlock用toolCall.id || ""建块并照常发出toolcall_start/delta/end(pi-ai 0.84.2 仍是这个行为)llm-pi-ai/src/stream.ts原样翻译,block-end带着空 id 进入 DSH 分片协议llm/src/assembler.ts对block-end是权威采纳,空 id 就此冻结进 assistant/message 并持久化callId: ''、name: ''执行,返回unknown tool "";这对空 id 的 call/result 也写入了会话日志tool_calls[0].id: ""随请求发出。这在 OpenAI 协议里是非法字段,模型服务拒绝有两点值得说明。
其一,这个问题不止存在于 pi-ai 这条路径。
llm-deepseek是完全自研的适配层(不经过 pi-ai),其translate.ts的closeBlock同样以id: CallId(block.callId ?? '')落盘空 id;且translate.spec.ts中有一条用例明确断言了空 id 直通("handles deltas that never carry id or name (empty-string fallbacks)")。也就是说,容忍空 id 进入持久层是 DSH 自身的协议约定,并非上游依赖的缺陷传染。其二,
llm-deepseek/src/serialize.ts里有一段现成的注释:"a null here bricks every later turn of that session"——content 为 null 的同类持久化故障此前已经修过。这表明这类"一条非法消息写死整个会话"的故障模式是已知的,只是tool_calls[].id这个变体漏掉了。修复
我在本地实现了修复:7 个文件,+132/−23,分四层,各层独立成立:
llm-pi-ai/src/stream.ts)——toolcall_delta/toolcall_end遇空 id(或空 name)直接丢弃,空 id 不进入 DSH 协议llm/src/assembler.ts,覆盖所有适配器)——空 id 的block-end不被采纳,partial 保持 open,最终经既有的call-${index}回退合成非空 id。模型收到的是可以自我纠正的UNKNOWN_TOOL而非永久 400;原有 fallback 行为不变,旧用例不改即通过llm-pi-ai/src/replay.ts、context.ts)——重放历史时跳过空 id 的 tool-call 块及空toolCallId的 tool resultllm-deepseek/src/serialize.ts)——同上,覆盖 deepseek 直连路径第 3、4 层的意义在于存量数据:已经被写坏的会话不需要修盘,装上修复后即可继续使用。
另有一条建议未包含在 patch 内:
llm-deepseek/src/translate.ts的closeBlock应按第 2 层同样处理,对应的直通用例需一并修改——它目前相当于把异常形态固化成了预期行为。验证:相关测试全部通过(llm 三个包 887、agent-loop 335、subagent + core-session 945),tsc host/client 双绿;新增 5 个回归用例,两个旧用例由"透传空 id"改为"丢弃"语义。
对已损坏的会话也做了就地修复验证:解压 transcript,将三条相关事件(assistant/message、tool/call、tool/result)中的空 id 统一改为合成的
call-8040+todo_write,重压回档,会话恢复正常。有了第 3、4 层之后此步骤不再必要,仅作为存量数据急救的参考。完整 diff
All reactions