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.
This commit is contained in:
parent
e91c28d3fd
commit
83931a5f3e
5 changed files with 12 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 协议。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
|
|
|||
|
|
@ -143,8 +143,9 @@ export async function* translate(payloads: AsyncIterable<string>): 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',
|
||||
|
|
|
|||
|
|
@ -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<ToolCallId>(`call-${index}`),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue