Repository navigation
[Ideas] One malformed tool-call argument kills the whole turn: MALFORMED_RESPONSE/STREAM_CLOSED are not default-retryable (46 turns lost in production audit) #8976
Replies: 4 comments 1 reply
|
Confirmed on the released line (0.1.7-rc.2), plus something that may make this solvable without a The two codes are not one adapter's quirk. In addition to the DeepSeek sites you cite There is a const alwaysPolicySchema = z.object({
mode: z.const("always").required(),
backoff: backoffSchema,
});and If you prefer the narrower route, One caveat for the proposal: the non-retryability of Two numbers that would strengthen the case a lot, if you have them:
|
|
Update — ready-to-apply patch. A fix branch is up on a public fork:
Post-fix field result from the same production machine (2026-10-06): two real occurrences of Ready-to-apply diff--- a/packages/llm/llm/src/retry-policy.ts
+++ b/packages/llm/llm/src/retry-policy.ts
@@ -14,11 +14,19 @@ const DEFAULT_JITTER_RATIO = 0.1
const DEFAULT_RETRYABLE_CODES = Object.freeze([
EMPTY_RESPONSE_CODE,
'RATE_LIMIT',
'SERVER',
'TIMEOUT',
'TRANSPORT',
+ // Stream-level degenerate completions: a malformed tool-call argument is a
+ // sampling artifact of one completion (the identical request typically
+ // succeeds on re-issue), and a stream that ends before message_stop is the
+ // same transient class as TRANSPORT/TIMEOUT. Terminating the turn on the
+ // first occurrence converts a one-off model hiccup into a user-visible
+ // failure of the whole agent turn.
+ 'MALFORMED_RESPONSE',
+ 'STREAM_CLOSED',
])
--- a/packages/llm/llm/tests/retry-policy.spec.ts
+++ b/packages/llm/llm/tests/retry-policy.spec.ts
@@ -13,7 +13,9 @@ it('resolves the default policy', () => {
mode: 'normal',
maxRetries: 5,
- retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'],
+ retryableCodes: [
+ 'EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT',
+ 'MALFORMED_RESPONSE', 'STREAM_CLOSED',
+ ],
initialDelayMs: 500,
maxDelayMs: 10_000,
jitterRatio: 0.1, |
|
在 0.2.1-rc.5(Desktop 2.0.17,Windows 11) 上重新确认该问题仍复现:2026-10-28→30 共 2 次 这里的补丁分支( 恳请维护者合入或给出目标版本;如果担心整步重试会连坐同一步里其它正常工具调用(#9402),可先只对“全部 tool-call 参数不可读且无 text/reasoning 伴随”的场景启用,正解思路(非致命工具错误 + 模型同轮自我修订)见 #11577。 |
Uh oh!
There was an error while loading. Please reload this page.
Summary
When the model emits a tool-call whose
argumentsstring is not valid JSON, the DeepSeek Messages adapter throwsLlmError("DeepSeek Messages stream: tool input is invalid JSON", "MALFORMED_RESPONSE"), and the default retry policy does not includeMALFORMED_RESPONSE— so a single bad sampling of tool arguments terminates the entire agent turn. In long-running sessions this surfaces as:… and the turn ends with
reason.kind: "error".Where
packages/llm/llm-deepseek/src/translate.ts— atmessage_stop, every tool-call block's accumulatedargumentsisJSON.parsed; failure →malformed('tool input is invalid JSON')→MALFORMED_RESPONSE.packages/llm/llm/src/retry-policy.ts—DEFAULT_RETRYABLE_CODES = [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]. NoMALFORMED_RESPONSE, noSTREAM_CLOSED.@deepseek-ai/dsh-llm-retryonly retries codes inpolicy.retryableCodes(normal mode), so the failure is terminal.The adapter README documents the asymmetry explicitly: "a terminal
stopwith no content blocks becomesEMPTY_RESPONSE, which the default retry policy retries" — but the other two stream-level failures are not retried.Evidence from production use
Full-text audit of 961 compressed session logs on one long-running machine (dsh 0.2.0-rc.2, provider
deepseek-account, macOS):tool input is invalid JSONturn/endevents carry{reason: {kind: "error", error: {code: "MALFORMED_RESPONSE"}}}— i.e. 46 turns were killed by thisllm/retryevents accompany those failures (confirming non-retryability)Behavior verified identical on upstream
masteras of 2026-10-06 (code search ontranslate.ts+retry-policy.ts).Why I think this is a transient, retryable failure
Malformed tool arguments are a sampling artifact of one completion, not a deterministic condition: the same request re-issued typically succeeds. The codebase itself already tolerates malformed tool arguments in other paths —
serialize.spec.tsasserts historical tool-call arguments like'{','','[]'are coerced to empty input rather than rejected.EMPTY_RESPONSE— a similarly degenerate model-output failure — was deliberately made default-retryable for the same reason.STREAM_CLOSED("stream ended before message_stop") is arguably the same class: a mid-stream drop with a partial response.Proposed fix (any of)
MALFORMED_RESPONSE(and considerSTREAM_CLOSED) toDEFAULT_RETRYABLE_CODES.TOOL_ARGS_MALFORMED) that is default-retryable, keepingMALFORMED_RESPONSEfor protocol violations.argumentsin the error message/failure payload — today it is undiagnosable from logs alone.Workaround (until fixed)
Per-provider config already supports it, since
retryPolicy.retryableCodesis user-overridable (volatile, hot-reloaded via the profile patch):Tested locally:
resolveRetryPolicyaccepts the extended list (positive + negative controls run), and the retry plugin then recovers instead of killing the turn.All reactions