Skip to content

normalizeDeclaredToolName maps exec_command→exec only when "exec" is declared; bare "exec" calls abort streams in shell mode #2641

Description

@blackhlen

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-responses adapter, statelessResponses: true (DeepSeek endpoint)
  • Model: deepseek-v4-flash, provider-level codexToolMode: "shell"

Evidence

  • normalizeDeclaredToolName in src/types/tools.ts short-circuits with if (!declared || !declared.has("exec")) return name; — the exec_command/shell_commandexec mapping 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 in responses-undeclared-tool-guard.ts (never legacy-normalize namespaced calls), so an MCP server that legitimately exposes exec under 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions