Skip to content

Recover DeepSeek tool calls from garbled DSML wrappers - #1189

Merged
edwin-zvs merged 1 commit into
mainfrom
worktree-dsml-generic-tool-parse
Aug 3, 2026
Merged

Recover DeepSeek tool calls from garbled DSML wrappers#1189
edwin-zvs merged 1 commit into
mainfrom
worktree-dsml-generic-tool-parse

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

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:

<||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 /Users/moon/construct/crates/daemon/src/router -name '*.rs' | wc -l</||DSML||tool_command>
</||DSML||tool_params>
</||DSML||invoke>
</||DSML||tool_calls>

This is a third markup shape, distinct from the two #1185 handles. Isolating the parser against it, each of three defects is independently fatal:

variant tools recovered
exact live payload 0
wrapper corrected to tool_calls 0
canonical parameter name= children, garbled wrapper kept 0
  1. Garbled openerollapse_tool_calls (a c eaten upstream) never matched the </…tool_calls> close, so the block failed to parse and StreamLift flushed it verbatim as assistant text.
  2. Repeated opener, single closeparse_tool_calls_body hit the duplicate, failed, and break'd, discarding the invoke that followed.
  3. Bespoke argument elementstool_method / tool_params / tool_command instead of parameter 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:

  • close tags match on a shared suffix (min 4 chars, so unrelated tags can't collide) rather than exact equality
  • a malformed opener is skipped rather than ending the scan
  • unrecognized wrappers recurse instead of discarding their contents
  • an invoke body harvests whatever children it carries: grouping elements (tool_params, parameters, args, …) flatten, tool_method supplies the tool name when the name attribute is missing, command-ish elements map onto cmd, and anything else becomes an argument keyed by its tag

Incomplete 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 path
  • streams_garbled_wrapper_payload_into_a_tool_call — SSE path, chunked on char boundaries as JSON-decoded deltas always are
  • incomplete_block_emits_no_tool_call — guards the recovery parsing against premature emission

cargo test -p construct-daemon: 640 passed, 0 failed. dsml.rs is fmt-clean; the crate has pre-existing fmt drift in unrelated files that this PR deliberately leaves alone.

Binary

Only crates/daemon is touched; the code ships in the construct binary (the daemon runs the router in-process):

/Users/moon/construct/.claude/worktrees/dsml-generic-tool-parse/target/debug/construct

No user-visible TUI surface changes, so no recording.

Follow-up, not fixed here

The loose _command arm from #1185 maps to a tool named shell, but Codex in these sessions exposes exec_command. A successful lift of that variant may still produce a function_call the 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.

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.
@edwin-zvs
edwin-zvs merged commit f2ab6e6 into main Aug 3, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the worktree-dsml-generic-tool-parse branch August 3, 2026 07:12
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant