diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index a0af3cb0e6..37d36945cb 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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-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 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 42bf460e52..3c9cb4f58d 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 2a13f0a740..1760951bdc 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -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(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 ### 将重试策略放在现有失败步骤扩展点上 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 a514432681..cbd236abf7 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: 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 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 a59337bc8f..e70d1048b5 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. 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 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 5185377adf..fa6e66595c 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` 加入默认可重试 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` 唯一监听的扩展点——并把流抛出的任何东西直接重抛出本轮。抛出的失败无论策略如何都会终结本轮且不重试。 ## 考虑过的替代方案 diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index f3ac0927a2..22980dd882 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -218,6 +218,9 @@ export async function* translate(payloads: AsyncIterable): 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, diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index c948c9da7a..eb4d17dc8a 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -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, diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 4d8db88402..90f3785315 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -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' diff --git a/snapshots/session/malformed-tool-call-retry/session.jsonl b/snapshots/session/malformed-tool-call-retry/session.jsonl new file mode 100644 index 0000000000..84592e46fc --- /dev/null +++ b/snapshots/session/malformed-tool-call-retry/session.jsonl @@ -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"}}} diff --git a/snapshots/session/malformed-tool-call-retry/snapshot.yml b/snapshots/session/malformed-tool-call-retry/snapshot.yml new file mode 100644 index 0000000000..40bb505dd2 --- /dev/null +++ b/snapshots/session/malformed-tool-call-retry/snapshot.yml @@ -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