Translate freeform tools instead of flattening them to no-arg functions - #1194
Merged
Conversation
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.
This is the root cause behind #1185 / #1189 / #1191. Those recovered DeepSeek's DSML markup after the fact; this stops the model needing to write markup at all.
Why Codex→DeepSeek never worked and Claude Code always did
The tell was that it's deterministic — not a flaky parse. Pulled Codex's actual outgoing request from its own transport log (
~/.codex/logs_2.sqlite, targetcodex_http_client::transport):Codex's only execution tool is
exec, a freeform grammar tool:It takes raw JavaScript source and declares no JSON schema.
responses::parse_requestmapped it to aCanonToolwhoseschemafell back to{"type":"object","properties":{}}, so DeepSeek was toldexecis a function taking no arguments. With no way to express the call, the model wrote prose describing it instead.And the name it wrote —
exec_command— appears in theexectool's own description ("await tools.exec_command(...)"), not in any tool it was offered. That also corrects the reasoning in #1189, which took that name as evidence the tools had arrived intact.Claude Code sends only JSON-schema function tools, loses nothing in translation, and works. Hence the clean 100%/0% split.
What this does
Carries freeform-ness through the canonical form and translates it in both directions:
customtool parsedformatinstead of discarding itcustomdeclaration back verbatimcustom_tool_callwith unwrapped raw text, notfunction_callwith JSONcustom_tool_call/_outputinput itemsArguments that don't parse as the synthesized schema pass through verbatim rather than being dropped, so a model that ignores the schema and streams the body directly still gets its text to the harness.
Tests
Eight added. The end-to-end one is
codex_exec_tool_reaches_a_chat_target_callable— Codex-shaped Responses request with the realexectool, emitted to a chat-completions target, asserting no tool is dropped andexecarrives with a required string argument. Verified it fails without the fix:Plus:
customrecorded as freeform; Responses target gets it verbatim; freeform call streams back ascustom_tool_call; a plain function tool is untouched; unparseable arguments pass through; multi-turn replay of a freeform call and its output.cargo test -p construct-daemon: 656 passed, 0 failed. Added lines are fmt-clean; the crate's pre-existing fmt drift is left alone.Scope
Touches all four dialect emitters, since every one of them advertises tools: chat, Anthropic, and Gemini now emit
schema_for_json_target(), which is a no-op for ordinary function tools.ClientEncoder::with_contextcarries the freeform tool names to the response encoder;ClientEncoder::newstill exists and defaults to none.Verification worth doing after merge
I have not been able to observe a live Codex→DeepSeek turn with this in place — that needs a daemon restart. The decisive check is one Codex turn producing a real
custom_tool_callrather than DSML prose.Binary
crates/daemononly — ships inconstruct:/Users/moon/construct/.claude/worktrees/responses-freeform-tools/target/debug/construct