Repository navigation
Replies: 1 comment
|
逐行复核了你的失败链:四个环节我都能在 ① 这个缺口被修过一次,又被完整回退了——回退的理由正是你的场景
即"首个 delta 带 id、后续 delta 重复空串/ 关键在后面:那次提交顺带做掉了你 §7 的 P0 前两条(新增
今天 ⇒ 你的报告恰好是那条被推迟路径的第一个真实证据。 这改变了提议性质:不是"修已知 bug",而是"重开一个被显式推迟、且上次实现被回退过的决策"。上次代价是真实的。所以重做时必须把"身份缺失"与"provider 已发出的 finish"正交处理,绝不再用 ② 空 id 落盘的闸门在写边界,不在组装/执行层(这让 P0 变便宜)你 §4.5"repair 不覆盖 balanced 空 id"准确,但还有更基础一层:校验器早就有,写路径根本没调用它。
⇒ 不变量应是"读得进才写得出",实际是"写得进、读不出":写路径放行了它自己读取器会拒收的事件。这就是你 P1 那个洞的精确位置。 由此得到比组装层更便宜、且完全不碰 finish reason 的修法:在 append(或 append 前复用 两条既有约束也印证"清洗"而非"放松读侧": ③ 你 P0 第一条的
|
| 优先级 | 位置 | 建议 |
|---|---|---|
| P0 | 写边界:append(core/session/src/index.ts:739,或 append 前置校验) |
对 tool/result 的 source.callId 做非空校验,与读侧 :369-374 用同一谓词。零新错误码、零重试集合改动、零 finish 覆盖,直接堵住"写得进读不出"。 |
| P0' | 产侧:llm-pi-ai/src/stream.ts:188/200、llm-deepseek/src/translate.ts:191 |
身份缺席 ⇒ 不产出可派发调用。必须按 b03261caad 的教训:不得用 [DONE] 门覆盖 provider 已发出的 finish(尤其 max-tokens),应做成"不产出该块 + 标记本次响应不可用",与 provider finish 正交。 |
| P1 | 回放清洗(llm-pi-ai/src/context.ts:202 之前) |
丢弃或补齐"toolCallId 为空、或与该助手消息 tool_calls 对不上"的 tool-result。你这条对;:382 的配对断言说明清洗是唯一方向。 |
| P1' | 恢复:400 含 tool_call_id 反序列化错误 |
写边界修好后新增价值下降(新会话不再产毒),仍可作存量救援。注意 interruptedTurnClosers 只处理"不平衡尾部",毒记录是平衡的,需新增形状识别。 |
| P2 | repair 模块 | 同上,针对存量。 |
⑧ 旁注:toolName 也一起失真
llm-pi-ai/src/context.ts:202-203 回放时 toolCallId 原样映射,且 toolName: toolNames.get(result.toolCallId) ?? 'unknown'——toolNames 也按 callId 建立。空 id 时写出去即 toolName: 'unknown':工具名与 id 双双失真。若上游清洗,可恢复顺序是 name(若调用名尚存)→ id;两边都空只能判为无法回填。
⑨ 核验边界
- 行号与谓词基于
c291e7961a(=dsh-v0.1.5-rc.2),你基于0.1.5-rc.1;就这几处而言两版一致(alpha.1→rc.2 间llm/jobs的 src 只动了 README 与 package.json)。 - 我未在你的环境复现(无你的 provider 与完整日志),以上为源码级复核 + 你提供的日志节选。
- 从 §5.2 的原始 chunk(显式
id:""、后续块均无 name)看,事故 B 更接近"从未出现身份"而非"身份被抹";事故 A 我无法判定。
EN TL;DR. Your four-layer chain checks out on c291e7961a, but two repo facts reorder the fix. (1) The identity-erasure path was already closed in dsh-v0.1.3-alpha.1 (a1271a4903); that commit's companion mechanism — a new MALFORMED_TOOL_CALL code plus a [DONE] gate — was fully reverted in b03261caad (reason: the gate overrode a provider-sent finish, turning a safe max-tokens truncation into up to 5 retries; "no report describes a stream that omits identity entirely" — your report is the first such evidence, so re-proposing it needs the finish-reason separation spelled out). (2) The durable gate already exists but only on the read boundary (assertMessageEventShape, core/session/src/index.ts:369-374, via adoptSessionEvent/validateStoredEvents); Session.append calls only validateSessionEventData (:739), which never inspects callId — so the cheapest P0 is a non-empty callId check at append: no new error code, no retry-set change, no finish-reason involvement. Also: brandString is a compile-time cast, so the "brandString fallback for empty id" is a no-op; and block-end is authoritative (llm/src/assembler.ts:109), so fixing only deltas is inert. Both shipped adapters (llm-pi-ai/src/stream.ts:188/200; llm-deepseek/src/translate.ts:191) emit '' when identity never arrives; since api: openai-completions is a pi-ai route key (catalog.ts:312), your path is most likely llm-pi-ai, not llm-deepseek — worth stating in the report. Producer-seam mitigation for this shape already exists: @argszero/cordis-plugin-llm-tool-call-guard (repairs the empty id on both delta and block-end; repair: 'error' cuts the stream before dispatch) — mitigation, not the fix, since the append boundary still admits it.
Uh oh!
There was an error while loading. Please reload this page.
0.1.5-rc.1(文中代码行号均基于该版本;旧版 DSH 上同类事故同样存在,见 §5.2)1. 摘要(TL;DR)
当 LLM 上游(OpenAI 兼容流式协议)因抖动产生空
id/ 空name的 tool-call 块时(伴随 500 风暴、断流等场景),DSH 在四个环节均无拦截:""穿透??兜底,组装出{id:"", name:"", arguments:"完整参数"}的 tool-call 块;unknown tool ""(ToolNotFoundError,codeUNKNOWN_TOOL);toolCallId原样写入持久化会话日志;toolCallId直通 OpenAI wire 序列化 → 上游拒绝整个请求:400 json_parse_error: messages[N]: missing field tool_call_id;且 400(INVALID_REQUEST)不在可重试错误集合内,内置会话修复模块也不覆盖"平衡但空 id"的记录。结果:毒记录一旦落盘,该会话的后续每一次 LLM 请求(包括用户新消息)都携带毒记录并被拒绝,会话永久死亡,无自愈、无恢复路径。
两次独立事故(不同 DSH 版本、不同会话)呈现完全相同的终端签名;其中一次已做到逐行源码级验证。
2. 环境
0.1.5-rc.1(Node v22.23.2,Linux)api: openai-completions;名称已隐去)@deepseek-ai/dsh-llm、dsh-agent-loop、dsh-tools、dsh-llm-pi-ai、dsh-session(repair)、dsh-llm-retry、@earendil-works/pi-ai3. 触发条件(上游侧背景)
500 "Generation failed, try later"(会话日志中均有对应llm/retry记录);旧会话事故中还有Stream ended without finish_reason(TRANSPORT 断流)与500 "upstream error: do request failed"。id与function.name,后续 chunk 只携带arguments片段。流中断/重组时若首个 chunk 丢失,即产出"完整 arguments + 空 id/name"的畸形块——与两起事故记录到的毒块形状完全一致(旧版日志直接记录了原始 chunk:id为显式空字符串,见 §5.2)。4. 故障链与代码位置(0.1.5-rc.1 逐行验证)
4.1 组装层:空字符串穿透
??兜底dsh-llm/lib/index.js(BlockAssembler):缺陷:
??只对null/undefined兜底;上游送来显式空字符串id: ""(两起事故的实际形态)时兜底不生效,组装出空 id 块;空 name 更是设计性产出(?? "")。4.2 执行层:空 name 直通工具注册表
dsh-agent-loop/lib/index.js:空 name 进入工具调度器的注册表查询 → 查无
""→dsh-tools/lib/index.jsL2449 抛出unknown tool ""(错误码UNKNOWN_TOOL)。缺陷:畸形块被当作正常调用"执行",且以工具执行错误形态进入结果记录,而不是作为"本步流异常"被丢弃/重试。
4.3 记录层:空 toolCallId 原样持久化
毒块对应的
tool/call+tool/result记录成对、平衡地写入会话日志(toolCallId: "")。4.4 回放层:空值直通 wire 序列化
上游拒绝整个请求,错误原文(节选):
缺陷:回放路径对"空/孤儿 toolCallId"无任何清洗。注:
messages[467]/[614]索引均指向请求消息数组尾部(毒记录所在位置),与"最后一次正常 step 的记录"相符。4.5 恢复层:无任何自愈路径
llm/retry记录中的 policyKey):["normal",5,["EMPTY_RESPONSE","RATE_LIMIT","SERVER","TIMEOUT","TRANSPORT"],500,10000,0.1]—— 400 的
INVALID_REQUEST不在可重试集合,一次 400 即 turn 终止。dsh-session/lib/types/repair.js(interruptedTurnClosers):只处理崩溃尾部不平衡(有 call 无 result,合成补 result);本事故的毒记录是平衡的(call/result 成对、id 为空),模块判定"无需修复"直接返回。5. 事故证据(日志节选)
5.1 事故 A(Session A)
llm/retry记录,failure 均为500: {"message":"Generation failed, try later","type":"upstream_error","code":"500"},重试后恢复{"type":"tool-call","id":"","name":"","arguments":"{...完整 bash 命令...}"}tool/call:{"turn":11,"step":59,"callId":"","name":"","arguments":"{...}"}tool/result:{"toolCallId":"","isError":true,"text":"Error: unknown tool \"\""},error:{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}turn/end(step 60):400 ... messages[467]: missing field tool_call_id ... json_parse_error5.2 事故 B(Session B,旧版 DSH)
{"type":"tool-call-delta","index":2,"id":"","argumentsDelta":"{"}—— 显式空字符串 id;后续块均无 name 字段;块结束记录组装为{id:"", name:"", arguments:"完整"}。tool/call记为callId:"call_bb923…",name:"todo_write"),但执行层仍收到空 name(result:unknown tool "")——即使记录被修补,回放仍 400(turn 44/45 均为messages[614]: missing field tool_call_id)。Stream ended without finish_reason(TRANSPORT)与 500upstream error: do request failed。6. 后果
unknown tool ""),会话是"从下一个消息起 400"。7. 推荐修复位置(供参考,按性价比排序)
dsh-llmBlockAssembler(L978-979)id走brandString兜底(partial.toolCallId || brandString(...));空name的 tool-call 块判定为畸形:不产出该块,将本次 LLM 调用标记为 step 级失败(如MALFORMED_TOOL_CALL错误码并纳入可重试集合),不落盘。dsh-agent-loopexecuteToolCalls(L512-518)name非空且已注册;失败时不记录tool/call+tool/result毒对,改记"本步流异常、可重试"的 step 级事件。dsh-llm-pi-aiL1263/1327 → wire 序列化前)toolCallId为空、或与该助手消息tool_calls的 id 对不上的 tool-result 消息,丢弃或合成有效 id(一次性清除已落盘的存量毒记录)。tool_call_id反序列化错误时:执行 P1 清洗 + 自动重试一次(把"永久损毁"降级为"一次可恢复的 400")。dsh-session/repair.jsinterruptedTurnClosers增加对"平衡但空 id 的 tool 对"的识别与清洗(覆盖存量损坏会话的恢复入口)。0.1.5-rc.1(all line numbers refer to this build; an identical incident occurred on an older DSH build, see §5.2-EN)1. Summary (TL;DR)
When an OpenAI-compatible LLM upstream emits a tool-call block with an empty
idand emptynamedue to jitter (500 storms, stream truncation, …), DSH has no guard at any of four layers:""slips past the??fallback, producing a{id:"", name:"", arguments:"full args"}tool-call block;unknown tool ""(ToolNotFoundError, codeUNKNOWN_TOOL);toolCallId;toolCallIdflows straight into the OpenAI wire serialization → the upstream rejects the whole request:400 json_parse_error: messages[N]: missing field tool_call_id; and 400 (INVALID_REQUEST) is not in the retryable error set, while the built-in session-repair module does not cover "balanced but empty-id" records.Consequence: once the poison record is on disk, every subsequent LLM request for that session (including new user messages) carries the poison and is rejected — the session is permanently dead, with no self-heal and no recovery path.
Two independent incidents (different DSH builds, different sessions) show the identical terminal signature; one of them is verified line-by-line against the source.
2. Environment
0.1.5-rc.1(Node v22.23.2, Linux)api: openai-completions; name redacted)@deepseek-ai/dsh-llm,dsh-agent-loop,dsh-tools,dsh-llm-pi-ai,dsh-session(repair),dsh-llm-retry,@earendil-works/pi-ai3. Trigger conditions (upstream-side context)
500 "Generation failed, try later"within a single turn (each with a correspondingllm/retryrecord in the session log); the older incident also hadStream ended without finish_reason(TRANSPORT) and500 "upstream error: do request failed".idandfunction.name, and later chunks carry onlyargumentsfragments. If that first chunk is lost during a stream break/reassembly, the assembled tool call is "full arguments + empty id/name" — exactly the shape recorded in both incidents (the v0-format log captured the raw chunks:idwas an explicit empty string, see §5.2-EN).4. Failure chain and code locations (verified line-by-line on 0.1.5-rc.1)
4.1 Assembly: empty string slips past the
??fallbackdsh-llm/lib/index.js(BlockAssembler):Defect:
??only catchesnull/undefined; an explicit empty stringid: ""(the actual shape in both incidents) defeats the branded-id fallback. An emptynameis even produced by design (?? "").4.2 Dispatch: empty name goes straight to the tool registry
dsh-agent-loop/lib/index.js:The empty name reaches the registry lookup → miss on
""→dsh-tools/lib/index.jsL2449 throwsunknown tool ""(codeUNKNOWN_TOOL).Defect: the malformed block is executed like a normal call and its failure enters the record layer as a tool-execution error, instead of being dropped/retried as a step-level stream anomaly.
4.3 Record layer: empty toolCallId persisted as-is
The poison
tool/call+tool/resultpair is written to the durable log balanced and paired (toolCallId: "").4.4 Replay: empty value flows straight into wire serialization
The upstream rejects the whole request (error excerpt):
Defect: the replay path performs no sanitization of empty/orphan
toolCallId. Note that themessages[467]/[614]indices point at the tail of the request's message array (the poison record's position), consistent with "the record of the last normally-completed step".4.5 Recovery: no self-heal path at all
llm/retrypolicyKey in the session logs):["normal",5,["EMPTY_RESPONSE","RATE_LIMIT","SERVER","TIMEOUT","TRANSPORT"],500,10000,0.1]— a 400
INVALID_REQUESTis not retryable; one 400 ends the turn.dsh-session/lib/types/repair.js(interruptedTurnClosers): only handles unbalanced crash tails (a recorded call with no result gets a synthetic result). This incident's poison is balanced (call/result paired, id empty), so the module decides "nothing to repair" and returns.5. Incident evidence (log excerpts)
5.1 Incident A (Session A)
llm/retryrecords, each500: {"message":"Generation failed, try later","type":"upstream_error","code":"500"}, all recovered by retry{"type":"tool-call","id":"","name":"","arguments":"{...a full bash command...}"}tool/call:{"turn":11,"step":59,"callId":"","name":"","arguments":"{...}"}tool/result:{"toolCallId":"","isError":true,"text":"Error: unknown tool \"\""},error:{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}turn/end(step 60):400 ... messages[467]: missing field tool_call_id ... json_parse_error5.2 Incident B (Session B, older DSH build)
{"type":"tool-call-delta","index":2,"id":"","argumentsDelta":"{"}— an explicit empty-string id; nonamefield on any following chunk; the block-end record assembled{id:"", name:"", arguments:"complete"}.tool/callstored ascallId:"call_bb923…",name:"todo_write"), but the dispatch layer still received an empty name (result:unknown tool "") — even with the record patched, replay still 400'd (turns 44/45, bothmessages[614]: missing field tool_call_id).Stream ended without finish_reason(TRANSPORT) and a 500upstream error: do request failed.6. Impact
unknown tool ""); the session simply "400s from the next message onward".7. Suggested fix locations (for reference, ordered by cost/benefit)
dsh-llmBlockAssembler (L978-979)brandStringon emptyid(partial.toolCallId || brandString(...)); treat a tool-call block with emptynameas malformed: do not emit the block, mark the LLM call as a step-level failure (e.g. aMALFORMED_TOOL_CALLerror code, added to the retryable set), and do not persist it.dsh-agent-loopexecuteToolCalls(L512-518)name(non-empty and registered) before dispatch; on failure, do not record the poisontool/call+tool/resultpair — record a "step-level stream anomaly, retryable" event instead.dsh-llm-pi-aiL1263/1327 → before wire serializationtoolCallIdis empty or matches notool_callsid in the preceding assistant message (one-shot cleanup of already-persisted poison).tool_call_iddeserialization error: run the P1 sanitization and auto-retry once (downgrade "permanent death" to "one recoverable 400").dsh-session/repair.jsinterruptedTurnClosersto detect and clean "balanced but empty-id" tool pairs (recovery entry point for already-corrupted sessions).All reactions