From 83931a5f3ee97e6438a0b514765525512c54e342 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 10:08:42 +0800 Subject: [PATCH] fix(llm): correct the refusal's recorded rationale `LlmRuntime.adapterStream` normalizes a thrown `LlmError` into the same error `finish` the loop routes to `agent/request-error`, so throwing would reach retry too. The Note claimed otherwise. Yielding is chosen because it reports the attempt's billed usage first and matches the neighbouring `EMPTY_RESPONSE` refusal. The rejection comment repeats the corrected durability wording, and the assembler's delta-only fallback carries a TODO for the empty name it still invents for adapters that never close a tool-call block. --- .../2026-09-01-streamed-tool-call-identity.i18n.yaml | 4 ++-- .../architecture/2026-09-01-streamed-tool-call-identity.md | 2 +- .../2026-09-01-streamed-tool-call-identity.zh.md | 2 +- packages/llm/llm-deepseek/src/translate.ts | 5 +++-- packages/llm/llm/src/assembler.ts | 5 +++++ 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml index cbd236abf7..11b7087b8b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md -2026-09-01-streamed-tool-call-identity.md: e70d1048b51c63b5be64a11ecb18879ecc0c13fd -2026-09-01-streamed-tool-call-identity.zh.md: fa6e66595c0f25969e532854a6cebf37e4ef0def +2026-09-01-streamed-tool-call-identity.md: d940b0eb376928a4217eb69d1fb8f4aafa1796a0 +2026-09-01-streamed-tool-call-identity.zh.md: 153d9d4fc87cd5d440668b1b5e5cb6995628e2df diff --git a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md index e70d1048b5..d940b0eb37 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md +++ b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md @@ -16,7 +16,7 @@ The empty identity outlived the turn. `appendToolCall` and `appendToolResult` wr A tool call still lacking `id` or `name` when the stream reaches `[DONE]` is not closed. The translator reports any pending usage, then ends the response with an error finish carrying the new `MALFORMED_TOOL_CALL` code, and emits no `block-end` at all. `closeBlock` returns which field is missing instead of substituting an empty string, so no path can assemble an unidentified tool call. -`MALFORMED_TOOL_CALL` joins the default retryable codes described in [bounded LLM request recovery](2026-06-21-bounded-llm-request-recovery.md). The failure must arrive as an error `finish` rather than a thrown `LlmError`: the agent loop derives `agent/request-error` — the only extension point `dsh-llm-retry` listens on — from `BlockAssembler.finish`, and rethrows whatever the stream throws straight out of the turn. A thrown failure ends the turn with no retry whatever the policy says. +`MALFORMED_TOOL_CALL` joins the default retryable codes described in [bounded LLM request recovery](2026-06-21-bounded-llm-request-recovery.md); retry reaches the attempt either way, because `LlmRuntime.adapterStream` normalizes a thrown `LlmError` into the same error `finish` the loop routes to `agent/request-error`. The refusal is yielded rather than thrown so the attempt's already-billed usage still reaches the loop before the failure, and so the refusal follows the same chunk protocol as the `EMPTY_RESPONSE` case beside it. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md index fa6e66595c..153d9d4fc8 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md @@ -16,7 +16,7 @@ DeepSeek SSE 翻译器对每个携带该字段的工具调用分片都直接赋 流到达 `[DONE]` 时仍缺少 `id` 或 `name` 的工具调用不会被闭合。翻译器先报告待发的用量,再以携带新增 `MALFORMED_TOOL_CALL` code 的错误 finish 结束响应,并且完全不发出 `block-end`。`closeBlock` 返回缺失的是哪个字段,而不是替换成空串,因此没有任何路径能组装出无身份的工具调用。 -`MALFORMED_TOOL_CALL` 加入[有界 LLM 请求恢复](2026-06-21-bounded-llm-request-recovery.zh.md)所述的默认可重试 code 集。该失败必须以错误 `finish` 而非抛出的 `LlmError` 抵达:agent 循环从 `BlockAssembler.finish` 派生 `agent/request-error`——`dsh-llm-retry` 唯一监听的扩展点——并把流抛出的任何东西直接重抛出本轮。抛出的失败无论策略如何都会终结本轮且不重试。 +`MALFORMED_TOOL_CALL` 加入[有界 LLM 请求恢复](2026-06-21-bounded-llm-request-recovery.zh.md)所述的默认可重试 code 集;两种投递方式都能让重试覆盖该尝试,因为 `LlmRuntime.adapterStream` 会把抛出的 `LlmError` 归一化为同一个错误 `finish`,再由循环路由到 `agent/request-error`。这里以 yield 而非抛出给出拒绝,是为了让该尝试已计费的用量仍先抵达循环,并让拒绝与紧邻的 `EMPTY_RESPONSE` 走同一套 chunk 协议。 ## 考虑过的替代方案 diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index 22980dd882..3d98c31f4b 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -143,8 +143,9 @@ export async function* translate(payloads: AsyncIterable): AsyncGenerato for (const block of order) { const closed = closeBlock(block) if ('unidentified' in closed) { - // Nothing durable is written for a rejected response, so the usage - // the attempt already burned is still reported before the failure. + // A rejected response commits no assistant message, tool call, or + // tool result; the streamed chunks before it stay in the log, so the + // usage the attempt already burned is reported before the failure. if (pendingUsage) yield { type: 'usage', usage: pendingUsage } yield { type: 'finish', diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 88ae77d80b..09e9f81a31 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -110,6 +110,11 @@ export class BlockAssembler { switch (partial.blockType) { case 'text': return { type: 'text', text: partial.text } case 'reasoning': return { type: 'reasoning', text: partial.text } + // TODO: the delta-only fallback still invents an empty name for a tool + // call that never carried one. No shipped adapter reaches it — the + // DeepSeek translator refuses such a response outright (see + // .agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md) + // — but a future delta-only adapter would assemble an undispatchable call. case 'tool-call': return { type: 'tool-call', id: partial.toolCallId ?? brandString(`call-${index}`),