fix(codex): clamp oversized Responses call_id so MCP tools don't brick sessions (#73492)

The codex app-server namespaces MCP tool call ids as
codex_mcp__<server>__<tool>_<codex_call_id>. With an exec-<uuid> component the
built-in hermes-tools server alone overflows the Responses API's 64-char
call_id limit, so the request 400s with a non-retryable "string too long".
The offending item sits near the head of the transcript and replays every
turn, permanently bricking the session — the only recovery is /reset.

Sibling defect to #10788, which clamped input[*].id via
_MAX_RESPONSES_ITEM_ID_LENGTH. Apply the same treatment to call_id at both
Responses emit sites in _chat_messages_to_responses_input: a deterministic
surrogate (call_ + sha256[:32]) for ids over the limit, short ids unchanged.
Because the surrogate is a pure function of the original id, a function_call
and its matching function_call_output — which carry the same original id — map
to the same surrogate and stay paired without correlating the two items.
This commit is contained in:
PRATHAMESH75 2026-07-28 23:25:54 +05:30 committed by kshitij
parent 76b0ea5118
commit e45f2b39e2
2 changed files with 90 additions and 2 deletions

View file

@ -191,6 +191,28 @@ def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
return f"call_{digest}"
def _clamp_responses_call_id(call_id: str) -> str:
"""Keep a ``call_id`` within the Responses API's 64-char limit (#73492).
The codex app-server namespaces MCP tool call ids as
``codex_mcp__<server>__<tool>_<codex_call_id>``; with an ``exec-<uuid>``
component the built-in ``hermes-tools`` server already overflows 64 chars,
and the Responses API rejects the whole payload with a non-retryable HTTP
400 that then replays every turn permanently bricking the session.
Sibling defect to #10788 (which clamped ``input[*].id``), applied here to
``call_id``. The surrogate is a pure, deterministic function of the
original, so the ``function_call`` and its matching ``function_call_output``
which carry the same original id map to the same surrogate and stay
paired without correlating the two items. Short ids pass through unchanged,
preserving prompt-cache prefixes.
"""
if len(call_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
return call_id
digest = hashlib.sha256(call_id.encode("utf-8", errors="replace")).hexdigest()[:32]
return f"call_{digest}"
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
"""Split a stored tool id into (call_id, response_item_id)."""
if not isinstance(raw_id, str):
@ -546,7 +568,7 @@ def _chat_messages_to_responses_input(
items.append({
"type": "function_call",
"call_id": call_id,
"call_id": _clamp_responses_call_id(call_id),
"name": fn_name,
"arguments": arguments,
})
@ -589,7 +611,7 @@ def _chat_messages_to_responses_input(
items.append({
"type": "function_call_output",
"call_id": call_id,
"call_id": _clamp_responses_call_id(call_id),
"output": output_value,
})

View file

@ -227,6 +227,72 @@ def test_chat_messages_to_responses_input_keeps_short_message_id():
assert message_item["id"] == _VALID_ITEM_ID
# The codex app-server overflows the Responses 64-char call_id limit for
# MCP-routed tools, e.g. codex_mcp__hermes-tools__web_search_exec-<uuid> (#73492).
_OVERSIZED_CALL_ID = "codex_mcp__hermes-tools__web_search_exec-" + "0" * 43
def test_chat_messages_to_responses_input_clamps_oversized_call_id():
"""An oversized call_id must be clamped to <=64 chars on BOTH the
function_call and its matching function_call_output, to the same surrogate,
so the pairing survives (#73492)."""
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"call_id": _OVERSIZED_CALL_ID,
"function": {"name": "web_search", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": _OVERSIZED_CALL_ID,
"content": "some result",
},
]
items = _chat_messages_to_responses_input(messages)
call = next(i for i in items if i.get("type") == "function_call")
output = next(i for i in items if i.get("type") == "function_call_output")
assert len(call["call_id"]) <= 64
assert call["call_id"] != _OVERSIZED_CALL_ID
# Deterministic surrogate — the pair must still reference the same id.
assert call["call_id"] == output["call_id"]
def test_chat_messages_to_responses_input_keeps_short_call_id():
"""A call_id already within the limit passes through unchanged (#73492)."""
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"call_id": "call_abc123",
"function": {"name": "web_search", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "some result",
},
]
items = _chat_messages_to_responses_input(messages)
call = next(i for i in items if i.get("type") == "function_call")
output = next(i for i in items if i.get("type") == "function_call_output")
assert call["call_id"] == "call_abc123"
assert output["call_id"] == "call_abc123"
def test_preflight_codex_input_items_drops_oversized_message_id():
items = _preflight_codex_input_items(
[