mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
fix(codex): drop oversized message ids on Responses input replay
Codex assigns assistant message items server-side ids that can run 400+ chars (base64 encrypted blobs), but the Responses API caps input[].id at 64 chars and rejects the whole request with a non-retryable HTTP 400. Once a session captures one of these long ids, every subsequent turn replays it and 400s forever, since the history persists it in codex_message_items. Add a 64-char length guard at both replay sites — the history-to- input converter and the final preflight gate — so oversized ids are dropped while short ids (msg_...) are kept for prefix-cache hits. Mirrors the existing pattern for reasoning items, which already strip their id before replay because store=False means the API can't resolve ids server-side anyway. Fixes #27038 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
dabae386ef
commit
0b2907f586
2 changed files with 137 additions and 2 deletions
|
|
@ -288,6 +288,13 @@ _RESPONSES_BUILTIN_TOOL_TYPES = {
|
|||
|
||||
_RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"}
|
||||
|
||||
# The Responses API rejects input[].id longer than this with a non-retryable
|
||||
# HTTP 400 ("string too long"). Codex-issued assistant message ids are
|
||||
# server-assigned base64 blobs that can run 400+ chars, while Hermes-minted
|
||||
# ids (msg_...) stay well under this cap and are worth keeping for
|
||||
# prefix-cache hits. Drop only the oversized ones on replay.
|
||||
_MAX_RESPONSES_ITEM_ID_LENGTH = 64
|
||||
|
||||
|
||||
def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str:
|
||||
"""Normalize a Responses assistant message status for replay.
|
||||
|
|
@ -464,7 +471,9 @@ def _chat_messages_to_responses_input(
|
|||
}
|
||||
item_id = raw_item.get("id")
|
||||
if isinstance(item_id, str) and item_id.strip():
|
||||
replay_item["id"] = item_id.strip()
|
||||
stripped_id = item_id.strip()
|
||||
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
|
||||
replay_item["id"] = stripped_id
|
||||
phase = raw_item.get("phase")
|
||||
if isinstance(phase, str) and phase.strip():
|
||||
replay_item["phase"] = phase.strip()
|
||||
|
|
@ -718,7 +727,9 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
|
|||
}
|
||||
item_id = item.get("id")
|
||||
if isinstance(item_id, str) and item_id.strip():
|
||||
normalized_item["id"] = item_id.strip()
|
||||
stripped_id = item_id.strip()
|
||||
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
|
||||
normalized_item["id"] = stripped_id
|
||||
phase = item.get("phase")
|
||||
if isinstance(phase, str) and phase.strip():
|
||||
normalized_item["phase"] = phase.strip()
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ from types import SimpleNamespace
|
|||
import pytest
|
||||
|
||||
from agent.codex_responses_adapter import (
|
||||
_chat_messages_to_responses_input,
|
||||
_format_responses_error,
|
||||
_normalize_codex_response,
|
||||
_preflight_codex_api_kwargs,
|
||||
_preflight_codex_input_items,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -139,6 +141,128 @@ def test_normalize_codex_response_in_progress_message_still_incomplete():
|
|||
assert finish_reason == "incomplete"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replayed assistant message items with an oversized server-assigned ``id``
|
||||
# (Codex issues 400+ char base64 blobs) must never reach the API — the
|
||||
# Responses endpoint caps input[].id at 64 chars and rejects the whole
|
||||
# request with a non-retryable HTTP 400, permanently bricking the session
|
||||
# (every subsequent turn replays the same bad id). Short ids (msg_...) are
|
||||
# still worth keeping for prefix-cache hits, so this is a length guard, not
|
||||
# a blanket strip.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OVERSIZED_ITEM_ID = "x" * 408
|
||||
_VALID_ITEM_ID = "msg_abc123"
|
||||
|
||||
|
||||
def test_chat_messages_to_responses_input_drops_oversized_message_id():
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "pong",
|
||||
"codex_message_items": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "pong"}],
|
||||
"id": _OVERSIZED_ITEM_ID,
|
||||
"phase": "final_answer",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
items = _chat_messages_to_responses_input(messages)
|
||||
|
||||
message_item = next(item for item in items if item.get("type") == "message")
|
||||
assert "id" not in message_item
|
||||
assert message_item["phase"] == "final_answer"
|
||||
assert message_item["content"] == [{"type": "output_text", "text": "pong"}]
|
||||
|
||||
|
||||
def test_chat_messages_to_responses_input_keeps_short_message_id():
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "pong",
|
||||
"codex_message_items": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "pong"}],
|
||||
"id": _VALID_ITEM_ID,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
items = _chat_messages_to_responses_input(messages)
|
||||
|
||||
message_item = next(item for item in items if item.get("type") == "message")
|
||||
assert message_item["id"] == _VALID_ITEM_ID
|
||||
|
||||
|
||||
def test_preflight_codex_input_items_drops_oversized_message_id():
|
||||
items = _preflight_codex_input_items(
|
||||
[
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "pong"}],
|
||||
"id": _OVERSIZED_ITEM_ID,
|
||||
"phase": "final_answer",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert "id" not in items[0]
|
||||
assert items[0]["phase"] == "final_answer"
|
||||
|
||||
|
||||
def test_preflight_codex_input_items_keeps_short_message_id():
|
||||
items = _preflight_codex_input_items(
|
||||
[
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "pong"}],
|
||||
"id": _VALID_ITEM_ID,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert items[0]["id"] == _VALID_ITEM_ID
|
||||
|
||||
|
||||
def test_preflight_codex_api_kwargs_drops_oversized_message_id_end_to_end():
|
||||
kwargs = _preflight_codex_api_kwargs(
|
||||
{
|
||||
"model": "gpt-5.5",
|
||||
"instructions": "You are Hermes.",
|
||||
"input": [
|
||||
{"role": "user", "content": "ping"},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "pong"}],
|
||||
"id": _OVERSIZED_ITEM_ID,
|
||||
"phase": "final_answer",
|
||||
},
|
||||
],
|
||||
"tools": [],
|
||||
"store": False,
|
||||
}
|
||||
)
|
||||
|
||||
message_item = next(item for item in kwargs["input"] if item.get("type") == "message")
|
||||
assert "id" not in message_item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _preflight_codex_api_kwargs — built-in (provider-executed) tools must pass
|
||||
# through validation. Regression guard for the xAI native web_search
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue