Skip to content

Resolve DSML tool names against the tools a request offered - #1191

Merged
edwin-zvs merged 1 commit into
mainfrom
worktree-dsml-resolve-tool-names
Aug 3, 2026
Merged

Resolve DSML tool names against the tools a request offered#1191
edwin-zvs merged 1 commit into
mainfrom
worktree-dsml-resolve-tool-names

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

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 offers exec_command.

A function_call naming 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

TranslationContext already 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 emitting ToolStart.

Resolution is deliberately conservative, in order:

  1. name was actually offered → keep it
  2. single case-insensitive match → use it
  3. shell-ish guess (shell, exec_command, bash, run_command, run_terminal_cmd, execute_command) and exactly one shell-ish tool on offer → use it
  4. exactly one substring match either direction (execexec_command) → use it
  5. otherwise → leave the recovered name alone

Ambiguity 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 mismatch
  • keeps_a_name_the_request_actually_offered
  • keeps_the_recovered_name_without_an_offered_list
  • refuses_to_guess_between_ambiguous_candidates
  • resolves_a_unique_substring_match
  • leaves_an_unrelated_name_alone
  • streams_loose_command_markup_as_the_offered_shell_tool — markup in, dispatchable tool out

cargo 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_response for the chat dialect now takes the context (it previously took only the body), and Gemini's TranslationContext literal 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 cmd can 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/daemon only — ships in construct:

/Users/moon/construct/.claude/worktrees/dsml-resolve-tool-names/target/debug/construct

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
edwin-zvs merged commit 33e033e into main Aug 3, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the worktree-dsml-resolve-tool-names branch August 3, 2026 13:39
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