diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index ee75f4190e6d..edff776536e7 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -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_____``; with an ``exec-`` + 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, }) diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index eb690a9e3b82..a7c7ea25e8f1 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -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- (#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( [