fix(llm): cover the malformed tool-call path and correct its records

The `MALFORMED_TOOL_CALL` JSDoc claimed nothing durable is written, but the
loop appends an `assistant/chunk` for every yielded chunk; only the assistant
message and tool result are withheld. The bounded-recovery Note still listed
a five-code transient set, and neither Note linked the other.

A keyless `malformed-tool-call-retry` scenario now records the refusal, the
retry, and the absence of a `tool/call` for the failed attempt. A translator
case pins that an already closable block also withholds its `block-end`.
This commit is contained in:
Yichen Jiang
2026-09-02 10:01:56 +08:00
parent 96cbd8d9e6
commit e91c28d3fd
11 changed files with 69 additions and 10 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-06-21-bounded-llm-request-recovery.md
2026-06-21-bounded-llm-request-recovery.md: 42bf460e52133b2a5471479fa3d7647e70092b48
2026-06-21-bounded-llm-request-recovery.zh.md: 2a13f0a740348a5f74bd3d90120a148b25f2e870
2026-06-21-bounded-llm-request-recovery.md: 3c9cb4f58d7b1c0090216ac5bf753667680a3ff2
2026-06-21-bounded-llm-request-recovery.zh.md: 1760951bdc23bad7dffb90edd7c61d2545a3bd28
@@ -46,7 +46,7 @@ The agent loop passes the terminal finish's `LlmFailure` to `agent/request-error
Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them.
The shared transient-code set is intentionally small: adapter mappings for `RATE_LIMIT` and `SERVER`, explicit `TIMEOUT` and `TRANSPORT` codes for remote failures, and `EMPTY_RESPONSE` for a completed provider response with no content blocks. Both adapters classify the last case as an error finish; see [empty model responses are retryable](../bug-fix/2026-07-24-empty-model-response-is-retryable.md). Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
The shared transient-code set is intentionally small: adapter mappings for `RATE_LIMIT` and `SERVER`, explicit `TIMEOUT` and `TRANSPORT` codes for remote failures, `EMPTY_RESPONSE` for a completed provider response with no content blocks, and `MALFORMED_TOOL_CALL` for a streamed tool call the provider never identified. Both adapters classify the empty response as an error finish; see [empty model responses are retryable](../bug-fix/2026-07-24-empty-model-response-is-retryable.md). The DeepSeek adapter classifies the unidentified tool call the same way; see [streamed tool-call identity](2026-09-01-streamed-tool-call-identity.md). Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
### Put retry policy on the existing failed-step extension point
@@ -46,7 +46,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen
适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。
共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。
共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`,以及提供方始终未给出身份的流式工具调用使用的 `MALFORMED_TOOL_CALL`。两个适配器都会把空响应归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md)。DeepSeek 适配器以同样方式归类无身份的工具调用;详见[流式工具调用身份](2026-09-01-streamed-tool-call-identity.zh.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。
### 将重试策略放在现有失败步骤扩展点上
@@ -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: a59337bc8f4d0126584822cc2e01155d377b5603
2026-09-01-streamed-tool-call-identity.zh.md: 5185377adf9e8fb366dab3934c10f87e22be7830
2026-09-01-streamed-tool-call-identity.md: e70d1048b51c63b5be64a11ecb18879ecc0c13fd
2026-09-01-streamed-tool-call-identity.zh.md: fa6e66595c0f25969e532854a6cebf37e4ef0def
@@ -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. 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). 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.
## Alternatives considered
@@ -16,7 +16,7 @@ DeepSeek SSE 翻译器对每个携带该字段的工具调用分片都直接赋
流到达 `[DONE]` 时仍缺少 `id``name` 的工具调用不会被闭合。翻译器先报告待发的用量,再以携带新增 `MALFORMED_TOOL_CALL` code 的错误 finish 结束响应,并且完全不发出 `block-end``closeBlock` 返回缺失的是哪个字段,而不是替换成空串,因此没有任何路径能组装出无身份的工具调用。
`MALFORMED_TOOL_CALL` 加入默认可重试 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 集。该失败必须以错误 `finish` 而非抛出的 `LlmError` 抵达:agent 循环从 `BlockAssembler.finish` 派生 `agent/request-error`——`dsh-llm-retry` 唯一监听的扩展点——并把流抛出的任何东西直接重抛出本轮。抛出的失败无论策略如何都会终结本轮且不重试。
## 考虑过的替代方案
@@ -218,6 +218,9 @@ export async function* translate(payloads: AsyncIterable<string>): AsyncGenerato
block.name = acceptIdentity(block.name, call.function?.name)
const fragment = call.function?.arguments ?? ''
block.text += fragment
// An in-flight delta may precede the call's id. The empty stand-in never
// reaches a ContentBlock: `[DONE]` either has the identity by then or
// refuses the whole response before closing the block.
yield {
type: 'tool-call-delta',
index: block.index,
@@ -359,6 +359,24 @@ describe('translate: defensive tool-call branches', () => {
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', name: 'f', argumentsDelta: '' })
})
it('suppresses the block-end of an already closable block when a later tool call has no identity', async () => {
const chunks = await collect(translate(feed(
firstChunk,
{ choices: [{ delta: { content: 'Checking.' } }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{}' } }] } }] },
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
DONE,
)))
expect(chunks.some(chunk => chunk.type === 'block-end')).toBe(false)
expect(chunks.at(-1)).toEqual({
type: 'finish',
reason: {
kind: 'error',
failure: { message: 'model streamed a tool call with no id', code: MALFORMED_TOOL_CALL_CODE },
},
})
})
it('rejects a stream whose tool call never carries a name', async () => {
const chunks = await collect(translate(feed(
firstChunk,
+4 -2
View File
@@ -43,8 +43,10 @@ export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE'
* identified: its `id` or `name` was absent or empty by the end of the stream.
* Such a call cannot be dispatched, and its result cannot be paired back to the
* provider on the next request, so adapters classify it as this failure instead
* of emitting a tool call the loop would reject as unknown. Nothing durable is
* written for the attempt, so retry policy treats it as safe to repeat.
* of emitting a tool call the loop would reject as unknown. No assistant
* message or tool result is written for the attempt — only the streamed
* `assistant/chunk` events it already produced — so retry policy treats it as
* safe to repeat.
*/
export const MALFORMED_TOOL_CALL_CODE = 'MALFORMED_TOOL_CALL'
@@ -0,0 +1,27 @@
{"type":"session","version":0,"id":"{{session:1}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"This prompt first receives a tool call the model never named, then a retried reply."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"This prompt first receives a tool call the model never named, then a retried reply."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"This prompt first receives a","messageSeqs":[7],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"unnamed-call","argumentsDelta":"{\"path\":\"README.md\"}"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":14,"outputTokens":6}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model streamed a tool call with no name","code":"MALFORMED_TOOL_CALL"}}}}}
{"type":"llm/retry","data":{"retryId":"{{retry:1}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"MALFORMED_TOOL_CALL\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model streamed a tool call with no name","code":"MALFORMED_TOOL_CALL"}}}
{"type":"llm/retry-started","data":{"retryId":"{{retry:1}}","turn":1,"step":1,"retry":1}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,9 @@
version: 1
scenario: malformed-tool-call-retry
profile: headless
composition: retry
recording: authored
header:
class: retry
systemPromptSource: text-turn
toolSchemasSource: text-turn