mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-07 04:10:42 +00:00
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:
+2
-2
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user