Lift DeepSeek DSML tool markup into structured tool calls - #1185
Merged
Conversation
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
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.
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.
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 structuredtool_calls. The router forwarded that text as prose, so Codex never sawfunction_callitems, 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):Fix
router/translate/dsmlmodule that parses:<|DSML|tool_calls>/invoke/parameter||DSML||variant_command/<cmd>form (mapped to Codexshellwith{"cmd":…})finish_reasonbecomestool_useTest plan
cargo test -p construct-daemon --lib dsmlcargo test -p construct-daemon --lib router::translatecargo test -p construct-daemon --lib router::proxycargo build(worktree debug binary)construct new codex, switch native picker todeepseek-v4-flash, ask it tolsthe workspace — expect a real shell tool call, not DSML text