Skip to content

Lift DeepSeek DSML tool markup into structured tool calls - #1185

Merged
edwin-zvs merged 1 commit into
mainfrom
fix/deepseek-dsml-tool-calls
Aug 3, 2026
Merged

Lift DeepSeek DSML tool markup into structured tool calls#1185
edwin-zvs merged 1 commit into
mainfrom
fix/deepseek-dsml-tool-calls

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Summary

When a Codex session is switched to DeepSeek via Construct's native model picker (construct-deepseek/deepseek-v4-flash), DeepSeek V4 often emits tool intent as DSML markup inside assistant content instead of structured tool_calls. The router forwarded that text as prose, so Codex never saw function_call items, tools never ran, and the turn ended after a few seconds of fake “let me look…” + DSML dump.

Observed in session s8e4420fd3 (Codex → construct router → DeepSeek):

• Let me look at the project…

  <||DSML||_command>
  <cmd>ls -la /Users/moon</||DSML||_param>
  </||DSML||_command>

Fix

  • New router/translate/dsml module that parses:
    • Official V4 form: <|DSML|tool_calls> / invoke / parameter
    • ASCII ||DSML|| variant
    • Live double-fullwidth-pipe _command / <cmd> form (mapped to Codex shell with {"cmd":…})
  • Non-streaming chat decode runs the lift so Responses harnesses get real tool events and finish_reason becomes tool_use
  • Streaming path for OpenAI-chat targets holds partial DSML across SSE deltas and lifts on complete blocks / stop

Test plan

  • cargo test -p construct-daemon --lib dsml
  • cargo test -p construct-daemon --lib router::translate
  • cargo test -p construct-daemon --lib router::proxy
  • cargo build (worktree debug binary)
  • Manual: construct new codex, switch native picker to deepseek-v4-flash, ask it to ls the workspace — expect a real shell tool call, not DSML text

When Codex (or any Responses harness) is routed to DeepSeek V4, the model
sometimes emits tool intent as DSML inside assistant content instead of
structured tool_calls. The router passed that markup through as text, so
the harness never executed tools and the turn ended after a few seconds.

Parse DSML (official invoke/parameter form and the looser _command/<cmd>
form seen in live sessions) out of chat-completions content, emit
ToolStart/ToolArgsDelta events, and upgrade the stop reason to tool_use
so Codex receives real function_call items.
@edwin-zvs
edwin-zvs merged commit da8deea into main Aug 3, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the fix/deepseek-dsml-tool-calls branch August 3, 2026 05:08
edwin-zvs added a commit that referenced this pull request Aug 3, 2026
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 added a commit that referenced this pull request Aug 3, 2026
DSML command markup carries no tool name, so the lift guesses one, and
#1185 guessed `shell` — Codex offers `exec_command`. A function_call
naming a tool the harness never advertised is dropped, so the turn ends
tool-less exactly as it did when the markup was never lifted at all.
The recovery got the markup right and still lost the call.

Record the request's tool names on TranslationContext, which already
flows to both response decoders, and resolve a recovered name against
them. Resolution is deliberately conservative — exact match wins, then a
case-insensitive match, then a single shell-ish candidate for a
shell-ish guess, then a single substring match. Anything ambiguous, or
an empty offered list, leaves the recovered name alone: dispatching the
model's command into the wrong tool is worse than the harness rejecting
a name it does not know.

Argument keys are still taken from the markup rather than the tool's
schema, so a tool whose parameter is spelled something other than `cmd`
can still be called with the wrong key. That needs the schemas plumbed
through as well and is left for its own change.
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