Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions agent/codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,40 @@ def _chat_messages_to_responses_input(
# Input preflight / validation
# ---------------------------------------------------------------------------

#: The Responses API rejects a ``call_id`` longer than this with a
#: non-retryable HTTP 400 ``string_above_max_length``.
CALL_ID_MAX_LEN = 64


def _cap_call_id(call_id: str) -> str:
"""Fit ``call_id`` inside the Responses API's 64-character limit.

The codex app-server names MCP tool calls
``codex_mcp__<server>__<tool>_exec-<uuid4>``, which runs 79-94 characters in
practice — a 36-char uuid plus ``_exec-`` already leaves only 22 for the
server and tool names. Replaying such a transcript over the Responses wire
(a background-review fork, or any codex_app_server -> codex_responses
downgrade) therefore fails with:

Invalid 'input[N].call_id': string too long. Expected a string with
maximum length 64, but got a string with length 78 instead.

That error is non-retryable, so the whole call dies. Shorten instead:
keep a readable prefix and append a digest of the FULL original id so
distinct calls can never collide. Purely a function of the input, so
replays and prefix caches stay stable (AGENTS.md Pitfall #16 —
deterministic IDs in tool call history).

Ids already within the limit are returned untouched, so the normal
``call_...`` shape is unaffected.
"""
if len(call_id) <= CALL_ID_MAX_LEN:
return call_id
digest = hashlib.sha256(call_id.encode("utf-8")).hexdigest()[:16]
keep = CALL_ID_MAX_LEN - len(digest) - 1
return f"{call_id[:keep]}_{digest}"


def _preflight_codex_input_items(
raw_items: Any,
*,
Expand Down Expand Up @@ -633,7 +667,7 @@ def _preflight_codex_input_items(
normalized.append(
{
"type": "function_call",
"call_id": call_id.strip(),
"call_id": _cap_call_id(call_id.strip()),
"name": name.strip(),
"arguments": arguments,
}
Expand Down Expand Up @@ -674,7 +708,7 @@ def _preflight_codex_input_items(
normalized.append(
{
"type": "function_call_output",
"call_id": call_id.strip(),
"call_id": _cap_call_id(call_id.strip()),
"output": cleaned if cleaned else "",
}
)
Expand All @@ -685,7 +719,7 @@ def _preflight_codex_input_items(
normalized.append(
{
"type": "function_call_output",
"call_id": call_id.strip(),
"call_id": _cap_call_id(call_id.strip()),
"output": output,
}
)
Expand Down
88 changes: 88 additions & 0 deletions tests/agent/test_codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import pytest

from agent.codex_responses_adapter import (
CALL_ID_MAX_LEN,
_cap_call_id,
_chat_messages_to_responses_input,
_format_responses_error,
_normalize_codex_response,
Expand Down Expand Up @@ -537,3 +539,89 @@ def test_normalize_codex_response_xai_reasoning_without_marker_stays_incomplete(

assert finish_reason == "incomplete"
assert assistant_message.content == ""


# ---------------------------------------------------------------------------
# call_id length cap (Responses API rejects > 64 chars, non-retryably)
# ---------------------------------------------------------------------------

# Real ids observed from the codex app-server, which names MCP tool calls
# codex_mcp__<server>__<tool>_exec-<uuid4>. 82 and 94 characters.
_LONG_CALL_ID = (
"codex_mcp__hermes-tools__kanban_complete_exec-"
"34a055fe-2a0b-4d32-a499-dcd21100d76a"
)
_LONGER_CALL_ID = (
"codex_mcp__codex_apps__google_calendar.search_events_exec-"
"6450d423-18e8-4e0c-9b87-36124bb53370"
)


def test_preflight_caps_overlong_call_id_and_keeps_the_pair_matched():
"""A function_call and its output must still reference the SAME id.

Without the cap the Responses API returns a non-retryable
HTTP 400 string_above_max_length and the entire call dies.
"""
items = _preflight_codex_input_items([
{
"type": "function_call",
"call_id": _LONG_CALL_ID,
"name": "kanban_complete",
"arguments": "{}",
},
{
"type": "function_call_output",
"call_id": _LONG_CALL_ID,
"output": "ok",
},
])

call, output = items[0], items[1]
assert len(call["call_id"]) <= CALL_ID_MAX_LEN
assert call["call_id"] == output["call_id"], "pairing must survive the cap"
assert call["call_id"] != _LONG_CALL_ID


def test_preflight_caps_multimodal_tool_output_call_id_too():
"""The array-output branch is a separate write site — cap it as well."""
items = _preflight_codex_input_items([
{
"type": "function_call",
"call_id": _LONGER_CALL_ID,
"name": "search_events",
"arguments": "{}",
},
{
"type": "function_call_output",
"call_id": _LONGER_CALL_ID,
"output": [{"type": "input_text", "text": "found"}],
},
])

assert len(items[1]["call_id"]) <= CALL_ID_MAX_LEN
assert items[0]["call_id"] == items[1]["call_id"]


def test_preflight_leaves_normal_call_ids_untouched():
items = _preflight_codex_input_items([
{
"type": "function_call",
"call_id": "call_abc123",
"name": "read_file",
"arguments": "{}",
},
])

assert items[0]["call_id"] == "call_abc123"


def test_cap_call_id_is_deterministic_and_collision_resistant():
a = _LONG_CALL_ID
# Differs only in the final uuid character — the truncated prefix is
# identical, so only the digest can keep these apart.
b = _LONG_CALL_ID[:-1] + ("0" if _LONG_CALL_ID[-1] != "0" else "1")

assert _cap_call_id(a) == _cap_call_id(a)
assert _cap_call_id(a) != _cap_call_id(b)
assert len(_cap_call_id(a)) == CALL_ID_MAX_LEN