Repository navigation
normalizeDeclaredToolName maps exec_command→exec only when "exec" is declared; bare "exec" calls abort streams in shell mode #2641
Description
Summary
When a routed model is configured for shell tool mode (codexToolMode: "shell", i.e. tool_mode unset), the outbound request catalog declares exec_command (and apply_patch), but not exec. Models such as DeepSeek V4 Flash frequently emit the code-mode tool name exec — sometimes in the same turn as exec_command. Because normalizeDeclaredToolName only maps legacy shell names → exec when exec IS declared, bare exec calls stay undeclared in shell mode, and both the Responses passthrough guard (src/server/responses-undeclared-tool-guard.ts) and src/bridge.ts fail the stream:
stream disconnected before completion: routed provider emitted undeclared client tool "exec"; only request-declared tools may be called
This is the mirror image of #2112: that fix lets models that emit top-level exec_command run in shell mode, but the same models also emit exec, which shell mode does not declare.
Environment
- opencodex 2.33.0 (also present on current main)
- Provider:
openai-responsesadapter,statelessResponses: true(DeepSeek endpoint) - Model:
deepseek-v4-flash, provider-levelcodexToolMode: "shell"
Evidence
normalizeDeclaredToolNameinsrc/types/tools.tsshort-circuits withif (!declared || !declared.has("exec")) return name;— theexec_command/shell_command→execmapping is the only direction supported.- A single response state dump (
responses-state-spill) shows the model mixing both conventions:"exec"×72,"exec_command"×17,"apply_patch"×15. - Failures are intermittent because the model alternates between the two conventions; a turn is only aborted when it emits a bare
exec.
Suggested fix
Add the reverse normalization: when the catalog declares a legacy shell bridge name (exec_command/shell_command) and does not declare exec, map a bare exec call to the declared shell name:
export function normalizeDeclaredToolName(
name: string,
declared: ReadonlySet<string> | undefined,
): string {
if (!declared) return name;
if (declared.has("exec")) {
if (declared.has(name)) return name;
// When the catalog explicitly declares any legacy shell bridge name, the
// environment genuinely exposes that tool — turn normalization off so a
// call is never mis-routed to `exec`.
if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) {
return name;
}
return (LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(name) ? "exec" : name;
}
// Mirror direction: shell mode declares exec_command/shell_command, not exec.
// Accept a bare `exec` call as the declared shell tool.
if (name === "exec") {
for (const legacy of LEGACY_SHELL_BRIDGE_TOOL_NAMES) {
if (declared.has(legacy)) return legacy;
}
}
return name;
}Notes:
- Only normalize bare
exec. Namespaced calls must keep the existing full-wire-name rule inresponses-undeclared-tool-guard.ts(never legacy-normalize namespaced calls), so an MCP server that legitimately exposesexecunder its own namespace is unaffected. - Keep the existing rule that normalization stays off when the catalog itself declares a legacy name, to avoid mis-routing a genuinely exposed tool.
- This should be covered by the same fail-closed guard so the semantics stay consistent across the passthrough and bridged paths.
Impact
Any routed model that mixes exec / exec_command conventions while running in shell tool mode sees random stream aborts (error code undeclared_tool_call, 502 upstream_error) with no automatic retry. With codexToolMode now configurable per provider/model, this affects exactly the setups that #2112 was added to support.
Activity
github-actions commentedon Aug 26, 2026
Issue closed: use an issue template
Thanks for taking the time to submit this issue. It was closed automatically because it was not opened with a recognized issue template (Bug report, Feature request, Documentation, or Provider compatibility). Freeform and API-opened issues cannot skip this check.
Please edit the issue to add:
See the Contributing guide for details.
Once the report passes the automated checks, it will be reopened automatically unless a maintainer has changed its state.