Resolve DSML tool names against the tools a request offered - #1191
Merged
Conversation
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.
Follow-up to #1189, which flagged this as the remaining gap.
Problem
DSML command markup carries no tool name at all, so the lift has to invent one. #1185 invented
shell. Codex offersexec_command.A
function_callnaming a tool the harness never advertised is dropped, so the turn ends without running anything — the same visible outcome as markup that was never lifted. The recovery parsed the markup correctly and still lost the call.Fix
TranslationContextalready flows from request emission to both response decoders, so it's the natural carrier: record the request's tool names there, and resolve a recovered name against them before emittingToolStart.Resolution is deliberately conservative, in order:
shell,exec_command,bash,run_command,run_terminal_cmd,execute_command) and exactly one shell-ish tool on offer → use itexec→exec_command) → use itAmbiguity is not resolved. Dispatching the model's command into the wrong tool is worse than the harness rejecting a name it doesn't know, so two plausible candidates means no substitution. An empty offered list means "no information" — never "the request had no tools".
Tests
Seven added, covering each rung and the end-to-end path:
resolves_a_shell_guess_onto_the_offered_shell_tool— the live mismatchkeeps_a_name_the_request_actually_offeredkeeps_the_recovered_name_without_an_offered_listrefuses_to_guess_between_ambiguous_candidatesresolves_a_unique_substring_matchleaves_an_unrelated_name_alonestreams_loose_command_markup_as_the_offered_shell_tool— markup in, dispatchable tool outcargo test -p construct-daemon: 647 passed, 0 failed. Added lines are fmt-clean; the crate's pre-existing fmt drift is left alone.Scope
decode_full_responsefor the chat dialect now takes the context (it previously took only the body), and Gemini'sTranslationContextliteral picks up the new field via..default().Not fixed here
Argument keys still come from the markup, not the tool's schema — a tool whose parameter is spelled something other than
cmdcan be called with the wrong key and fail just as quietly. Fixing that means plumbing the schemas through too and matching against their properties; it deserves its own change rather than being bolted on here.Binary
crates/daemononly — ships inconstruct:/Users/moon/construct/.claude/worktrees/dsml-resolve-tool-names/target/debug/construct