Skip to content

Translate freeform tools instead of flattening them to no-arg functions - #1194

Merged
edwin-zvs merged 1 commit into
mainfrom
worktree-responses-freeform-tools
Aug 3, 2026
Merged

Translate freeform tools instead of flattening them to no-arg functions#1194
edwin-zvs merged 1 commit into
mainfrom
worktree-responses-freeform-tools

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

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, target codex_http_client::transport):

total tools: 3
  type=custom    name=exec                  has_parameters=False   keys=[description, format, name, type]
  type=function  name=wait                  has_parameters=True
  type=function  name=request_user_input    has_parameters=True

Codex's only execution tool is exec, a freeform grammar tool:

"format": {"type":"grammar","syntax":"lark","definition":"start: pragma_source | plain_source ..."}

It takes raw JavaScript source and declares no JSON schema. responses::parse_request mapped it to a CanonTool whose schema fell back to {"type":"object","properties":{}}, so DeepSeek was told exec is 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 the exec tool'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:

custom tool parsed records its format instead of discarding it
→ Responses target original custom declaration back verbatim
→ JSON-only target synthesized single required string arg, grammar in its description — callable
freeform call returning custom_tool_call with unwrapped raw text, not function_call with JSON
custom_tool_call / _output input items parsed, so a call and its result survive into the next turn

Arguments 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 real exec tool, emitted to a chat-completions target, asserting no tool is dropped and exec arrives with a required string argument. Verified it fails without the fix:

assertion `left == right` failed
  left: Null
 right: Array [String("input")]

Plus: custom recorded as freeform; Responses target gets it verbatim; freeform call streams back as custom_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_context carries the freeform tool names to the response encoder; ClientEncoder::new still 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_call rather than DSML prose.

Binary

crates/daemon only — ships in construct:

/Users/moon/construct/.claude/worktrees/responses-freeform-tools/target/debug/construct

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.
@edwin-zvs
edwin-zvs merged commit d3c22d4 into main Aug 3, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the worktree-responses-freeform-tools branch August 3, 2026 14:50
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