Repository navigation
How other agent platforms handle malformed tool-call arguments — and the two layers this repo is missing #9503
Replies: 2 comments
|
A field note from the code we run, in support of layer (1). On 0.1.7-rc.2 the hit we captured carried two calls in one step — one with unparseable arguments, one readable. A whole-step retry would have discarded the readable call, which is why we back handing the block back as an isError tool result instead: the turn survives, the good call still runs, and the model gets to correct the bad one. The block itself is kept, so the evidence stays in assistant/attempt.stream rather than being overwritten by a retry. |
|
补一个已落地的实现参考,对应你说的第一层(payload 层修复而不是重试层)。 规则集刻意只做「不发明结构」的四类改写:
拒绝修的: 来源:一条 401 轮真实会话里 9 次 |
Uh oh!
There was an error while loading. Please reload this page.
Scope. This is not another reproduction of the
MALFORMED_RESPONSEbug — that is already well covered (#7581, #7705, #8920, #8976, #9037, #9402, and #3234 / #1519 on the persistence side). This is a survey of what other agent platforms do with the same failure, plus the two layers this repo is missing. The recurring question in those threads is "should a bad sampling really kill the turn?" — and other platforms have already answered it, in three different ways.1. The failure, in one line, with the evidence I can add
message_stop→ accumulatedtool-callarguments are not valid JSON →malformed('tool input is invalid JSON')→MALFORMED_RESPONSE, which is not inDEFAULT_RETRYABLE_CODES, so the turn ends with no retry at all.From one 1064-turn session (DSH Desktop 2.0.17,
dsh-compaction-basic0.2.0-rc.2, routedeepseek-account/deepseek-flash):writeopen depth 1, still inside a string at EOF)ask_user_question"inside a CJK string — the same shape as #7581Two things worth recording, because they decide who owns the fix:
assistant/attempt.streamcontains the tool-callblock-end, and indsh-llm-deepseekthat chunk is emitted only from the provider'scontent_block_stop(lib/index.js:1966-1976) — it is never synthesized at stream end. A broken transport surfaces asTRANSPORTinstead, and the same session produced several of those the same day. So: the model finished its own message with an incomplete tool call.assistant/attemptat11:22:15.237→turn/endat11:22:15.245— 8 ms, and nollm/retryevent in between, unlike everyTRANSPORT/SERVERfailure in the same session, which get 5 attempts with backoff.(Per @argszero in #9402, the validation only runs when the finish reason is not
max-tokens— so a length truncation is already handled leniently somewhere else. The gap is the non-length case.)2. What other platforms do
repairToolCall— the app supplies a repair function; if none is registered, it throws a catchableToolCallRepairErrorinstead of killing the runai-tool-call-repair-error), vercel/ai#7789 "handle invalid tool calls", #7994MALFORMED_FUNCTION_CALLandUNEXPECTED_TOOL_CALLalongsideMAX_TOKENS_EXCEEDED/SAFETY_BLOCKED, so the app layer decidespackages/core/src/core/turn.tsMALFORMED_FUNCTION_CALL), i.e. treats it as an expected outcome, not an exceptionFinishReasontool_useblocks at allinvalid_tool_calls, retry-on-validation-failure, validate-then-repair layersThe failure itself is not a DeepSeek quirk — every vendor has it, which is why Gemini has a finish reason for it and why a repair layer became a standard part of these stacks. What differs is what the harness does with it.
3. The two layers this repo is missing
Layer 1 — the turn should survive. A parse failure is one sampling event, not a session condition. The argument that this validation is the outlier was already made in #7581: the other two consumers of the same field are deliberately lenient (
dsh-agent-loop.parseArguments()), anddsh-llm-pi-aigoes further and substitutes{}. Options, cheapest first:retryPolicy.retryableCodesis a supported provider-route field (whole-table replacement). This is available today, and [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 carries a ready patch branch (fix/default-retry-stream-failures) addingMALFORMED_RESPONSE+STREAM_CLOSEDto the default set.isErrordescribing the parse failure so the model re-emits the call in the next step. A single re-send of one tool call is far cheaper than re-sending the whole turn, and it needs no retry policy change.Layer 2 — the session should survive. #1519 and #3234 document the worse variant: truncated arguments are serialized back verbatim on every later request, so the session 400s forever (
Unterminated string…). #1519 carries a tested fix (validate at serialization, substitute{}when unparsable). Codex has the same class of entries — "task permanently bricked when historical tool-call arguments exceed 1 MiB", "persists malformedtool_search_callarguments, causing 400 … on resume" — so this is not a hypothetical.4. One internal inconsistency worth settling at the same time
dsh-llm-pi-aiturns malformed arguments into{}and runs the tool with empty input (#9066) — silently doing something the model never asked for.dsh-llm-deepseekkills the turn. Two adapters, two opposite policies, and neither is the one the lenient consumers assume. Whichever policy is chosen (layer 1, option 3 looks best), it should be documented once for all adapters rather than decided per adapter.中文摘要
这条不是又一份复现(复现见 #7581 / #7705 / #8976 / #9402 / #3234 / #1519),而是横向对照 + 分层提案。我能补的证据有两点:① 记录里的
block-end只在 provider 发content_block_stop时产生(dsh-llm-deepseek:1966-1976),所以流没被截断,是模型自己带着不完整的工具调用结束了消息;② 失败路径 8 毫秒就到turn/end、中间没有llm/retry,而同一天的 TRANSPORT 都有 5 次重试。其他平台的处理分三档:修+重试(opencode 两个 PR、AI SDK 的repairToolCall钩子)、做成一等状态(Gemini CLI 的MALFORMED_FUNCTION_CALL事件、Gemini API 的 finish reason)、宽容/回灌(LangChaininvalid_tool_calls、pydantic-ai、hermes 的 validate-then-repair);Claude Code 是最严的一档(整条响应丢弃),Codex 有恢复路径但也有"畸形参数落盘把会话锁死"的同类 issue。建议分两层修:第一层让这一轮活下来(最省的是把解析失败变成isError的工具结果让模型重发,而不是终止整轮;退一步用retryableCodes或容错解析);第二层让会话活下来(序列化时校验,抄 #1519 的实测补丁)。另外顺手消掉一个内部不一致:dsh-llm-pi-ai会把坏参数静默变成{}并照常执行工具(#9066),与 deepseek 适配器的"终止整轮"正好相反。All reactions