DSH / Atlas
2026-09-01implementedarchitecturearchived 2026-09-04

Streamed tool-call identity survives empty continuation deltas

流式工具调用身份不被空续传分片抹除

The DeepSeek SSE translator assigned `id` and `name` on every tool-call delta that carried the field, so a continuation delta repeating either as an empty string erased the identity established by the call's first delta. The assembled block reached the loop with an empty name, which the tool registry refuses as `unknown tool ""`, leaving the affected models unable to run any tool. Gateways that fill those fields with

English

Problem

The DeepSeek SSE translator assigned id and name on every tool-call delta that carried the field, so a continuation delta repeating either as an empty string erased the identity established by the call's first delta. The assembled block reached the loop with an empty name, which the tool registry refuses as unknown tool "", leaving the affected models unable to run any tool. Gateways that fill those fields with null erased the identity the same way, and WireToolCallDelta declared both as string | undefined, keeping the observed null out of the compiler's reach.

The empty identity outlived the turn. appendToolCall and appendToolResult write the block's id verbatim and no write path validates it, while adoptSessionEvent refuses a tool/result whose callId is empty, so the persistence coordinator wrapped that refusal in SessionPersistenceCorruptionError. A session that recorded one such call was writable and no longer loadable.

Decision

acceptIdentity accepts only a non-empty string for a tool call's id and name; undefined, null, '', and any non-string leave the established value in place. The assignment set only narrows, so no input reaches a worse outcome than before. WireToolCallDelta widens id, function.name, and function.arguments to admit null, putting the values gateways actually send into the type system and making the runtime guard load-bearing rather than speculative.

Alternatives considered

Concatenate id and name across deltas. Rejected: they are identity, not accumulation. Concatenation produces Globnull against a gateway that sends null, and a doubled name against one that repeats a non-empty value.

Refuse a conflicting non-empty identity mid-stream. Deferred: a gateway that fragments a long tool name would be refused for it, and no observed provider re-sends a different non-empty identity within one call index.

Refuse a response whose tool call never receives an identity. Deferred. It requires a new failure code, a change to the default retryable set, and a [DONE] gate that must not override the finish reason a provider already sent — cost and risk that the reported defect does not carry. The lenient wire it guards against is hypothetical: no report describes a stream that omits identity entirely.

Relax the session reader's empty-callId refusal. Rejected: an empty callId cannot be paired back to the provider on the next request, so accepting it moves the failure into the model request. That refusal is the durable-boundary gate; the producer was the defect.

Consequences

A continuation delta repeating identity empty or null is inert, so a call keeps the identity its first delta established, and the reported path to unknown tool "" and an unreadable session is closed. A stream that never carries identity at all still assembles an empty one, exactly as before; that path and the recovery of sessions already holding an empty callId are outside this change.

Testing

translate.spec.ts covers empty and null continuation deltas, a repeated identical identity, and parallel calls holding separate identities under empty continuations. The existing cases for a wire that omits identity entirely keep their recorded empty-identity output.

中文

问题

DeepSeek SSE 翻译器对每个携带该字段的工具调用分片都直接赋值 id 与 name,因此续传分片把其中任一字段重复发送为空串时,会抹掉该调用首个分片已建立的身份。组装出的块带着空名字进入循环,工具注册表以 unknown tool "" 拒绝它,受影响的模型上任何工具都跑不起来。把这些字段填成 null 的网关会造成同样的抹除,而 WireToolCallDelta 把两者都声明为 string | undefined,让实际观察到的 null 落在编译器视野之外。

空身份还会活过本轮。appendToolCall 与 appendToolResult 原样写入块的 id 且没有任何写入路径校验它,而 adoptSessionEvent 拒绝 callId 为空的 tool/result,持久化协调器于是把该拒绝包装成 SessionPersistenceCorruptionError。记录过一次这种调用的会话可写但不再可读。

决定

acceptIdentity 对工具调用的 id 与 name 只接受非空字符串;undefined、null、'' 以及任何非字符串都保留已建立的值。会触发赋值的输入集合只减不增,因此没有任何输入会比改动前更差。WireToolCallDelta 把 id、function.name 与 function.arguments 放宽到允许 null,使网关实际发送的值进入类型系统,运行时守卫因此是承重的而非臆测的。

考虑过的替代方案

跨分片拼接 id 与 name。 否决:它们是身份而非累积。面对发送 null 的网关,拼接产生 Globnull;面对重复发送非空值的网关,产生重复的名字。

流中途拒绝冲突的非空身份。 推迟:分片发送长工具名的网关会因此被拒,且没有观察到任何提供方在同一个调用 index 内改发不同的非空身份。

拒绝始终未获得身份的响应。 推迟。它需要新增失败 code、改动默认可重试集,还需要一个不得覆盖提供方已给出终止原因的 [DONE] 闸门——这些代价与风险,已报告的缺陷并不需要承担。它所防的宽松线上格式是假想的:没有任何报告描述过完全不发送身份的流。

放宽会话读取端对空 callId 的拒绝。 否决:空 callId 无法在下一次请求中与提供方配对,接受它只是把失败推进模型请求。该拒绝是持久化边界的闸门;缺陷在生产方。

后果

重复发送空或 null 身份的续传分片不产生作用,调用因此保有其首个分片建立的身份,通往 unknown tool "" 与不可读会话的已报告路径就此切断。完全不携带身份的流仍会组装出空身份,与改动前一致;该路径以及已经写入空 callId 的会话恢复都不在本次改动范围内。

测试

translate.spec.ts 覆盖空与 null 续传分片、重复的相同身份,以及空续传下并行调用各自保有身份。原有那些描述完全不发送身份的线上格式的用例,保留其记录的空身份输出。