Recover DeepSeek tool calls from garbled DSML wrappers - #1189
Merged
Conversation
The DSML lift added in #1185 handled two markup shapes, but live Codex→DeepSeek turns keep inventing new ones. A turn captured in session s7deb86bac emitted a third: <||DSML||ollapse_tool_calls> <||DSML||ollapse_tool_calls> <||DSML||invoke name="exec_command"> <||DSML||tool_method>exec_command</||DSML||tool_method> <||DSML||tool_params> <||DSML||tool_command>find … | wc -l</||DSML||tool_command> </||DSML||tool_params> </||DSML||invoke> </||DSML||tool_calls> Three things defeated the parser, any one of them fatal: - the opener is garbled (`ollapse_tool_calls`) so no close matched and the block never parsed, leaking the whole turn into the transcript - the opener repeats but closes once, so scanning aborted on the duplicate and dropped the `invoke` that followed it - arguments arrive as bespoke child elements rather than `parameter`, which the harvester ignored entirely Rather than add a fourth exact-shape arm, make parsing recovery- oriented: match close tags on a shared suffix, skip malformed openers instead of abandoning the scan, recurse into unrecognized wrappers so they cannot swallow a nested call, and harvest whatever child elements an `invoke` carries — flattening grouping elements, taking the tool name from `tool_method` when the attribute is absent, and mapping command-ish elements onto `cmd`. Incomplete blocks still buffer rather than emitting a truncated call, so the streaming path cannot invent a half-built tool.
This was referenced Aug 3, 2026
edwin-zvs
added a commit
that referenced
this pull request
Aug 3, 2026
…ns (#1194) Codex→DeepSeek never ran a tool. Not intermittently — every turn. The DSML recovery in #1185/#1189/#1191 was treating a symptom. Codex's real tool list, captured from its own outgoing request, is three entries, and its only execution tool is freeform: type=custom name=exec has_parameters=False (format: lark grammar) type=function name=wait has_parameters=True type=function name=request_user_input has_parameters=True `exec` takes raw JavaScript source and declares no JSON schema. The Responses parser mapped it to a CanonTool whose schema fell back to `{"type":"object","properties":{}}`, so DeepSeek was told `exec` is a function that takes no arguments. With no way to express the call the model wrote prose describing it instead — and the name it wrote, `exec_command`, comes from the `exec` tool's own description text, not from any tool it was offered. Claude Code sends only JSON-schema functions, loses nothing, and works; hence the clean 100%/0% split. Carry freeform-ness through the canonical form and translate it in both directions: - a `custom` tool records its `format`; a Responses target gets that declaration back verbatim - targets that speak only JSON-schema functions get a synthesized single required string argument, with the grammar in its description, so the tool is callable at all - a call to a freeform tool returns as `custom_tool_call` carrying the unwrapped raw text, since that is the item shape the harness declared the tool with and the only one it dispatches - `custom_tool_call` / `custom_tool_call_output` input items are parsed, so a freeform call and its result survive into the next turn instead of silently vanishing from history Arguments that do not parse as the synthesized schema are passed through verbatim rather than dropped, so a model that ignores the schema and streams the body directly still gets its text to the harness.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Codex routed to DeepSeek V4 still ends turns without running a tool, even with #1185 in place. Reproduced live on a restarted daemon carrying that fix —
function_call = 0, markup straight through into the transcript:This is a third markup shape, distinct from the two #1185 handles. Isolating the parser against it, each of three defects is independently fatal:
tool_callsparameter name=children, garbled wrapper keptollapse_tool_calls(aceaten upstream) never matched the</…tool_calls>close, so the block failed to parse andStreamLiftflushed it verbatim as assistant text.parse_tool_calls_bodyhit the duplicate, failed, andbreak'd, discarding theinvokethat followed.tool_method/tool_params/tool_commandinstead ofparameter name="…", which the harvester skipped, leaving empty arguments.Approach
The spelling varies turn to turn, so chasing each shape with another exact-match arm will keep losing. Parsing is now recovery-oriented:
invokebody harvests whatever children it carries: grouping elements (tool_params,parameters,args, …) flatten,tool_methodsupplies the tool name when thenameattribute is missing, command-ish elements map ontocmd, and anything else becomes an argument keyed by its tagIncomplete blocks still buffer rather than emitting a truncated call, so the streaming path can't invent a half-built tool.
Tests
Three added, all against the verbatim live payload:
lifts_garbled_wrapper_and_bespoke_parameter_elements— batch pathstreams_garbled_wrapper_payload_into_a_tool_call— SSE path, chunked on char boundaries as JSON-decoded deltas always areincomplete_block_emits_no_tool_call— guards the recovery parsing against premature emissioncargo test -p construct-daemon: 640 passed, 0 failed.dsml.rsis fmt-clean; the crate has pre-existing fmt drift in unrelated files that this PR deliberately leaves alone.Binary
Only
crates/daemonis touched; the code ships in theconstructbinary (the daemon runs the router in-process):/Users/moon/construct/.claude/worktrees/dsml-generic-tool-parse/target/debug/constructNo user-visible TUI surface changes, so no recording.
Follow-up, not fixed here
The loose
_commandarm from #1185 maps to a tool namedshell, but Codex in these sessions exposesexec_command. A successful lift of that variant may still produce afunction_callthe harness can't dispatch. Fixing it properly means giving the lift access to the request's tool list, which is a larger change than this one.