Refusing a response whose tool call never receives an identity needed a new failure code, a change to the default retryable set, and a `[DONE]` gate that overrode the finish reason a provider had already sent — turning a safe `max-tokens` truncation into up to five retries. The lenient wire it guarded against is hypothetical: no report describes a stream that omits identity entirely, and the pre-existing test for it is labelled as such. Only `acceptIdentity` and the widened wire types remain. They close the reported erasure and cannot reach a worse outcome than the previous assignment, because the set of inputs that assign only narrows.
3.1 KiB
Agent Note:流式工具调用身份不被空续传分片抹除
Status: implemented
English | 中文
问题
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 续传分片、重复的相同身份,以及空续传下并行调用各自保有身份。原有那些描述完全不发送身份的线上格式的用例,保留其记录的空身份输出。