mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
76b0ea5118
commit
e45f2b39e2
2 changed files with 90 additions and 2 deletions
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue