Files
hermes-agent/agent/codex_responses_adapter.py
Teknium 841d9ff4a8 refactor(agent/adapters): simplify codex responses adapter and runtime (-1080 LOC)
Split _preflight_codex_input_items into per-item-type helpers over a
_PreflightCtx; streaming assembly moves into _CodexResponseAssembler with a
per-event dispatch table; run_codex_app_server_turn loses its session/usage/
interrupt regions to _ensure_codex_session/_finish_codex_turn/
_consume_user_interrupt/_queue_token_counts (counts built lazily so stub agents
without a session DB are never touched). Responses input/normalization and
stream callback order verified byte-identical against merge-base.
2026-09-02 13:29:47 -07:00

1320 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Codex Responses API adapter.
Stateless format-conversion and normalization for the OpenAI Responses API
(OpenAI Codex, xAI, GitHub Models and other Responses-compatible endpoints).
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import unicodedata
import uuid
from types import SimpleNamespace
from typing import Any, Callable, Dict, Iterator, List, NamedTuple, Optional, TypeGuard
from agent.message_sanitization import deterministic_call_id
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY
logger = logging.getLogger(__name__)
def _classify_responses_issuer(
*, is_xai_responses: bool = False, is_github_responses: bool = False, is_codex_backend: bool = False,
base_url: Optional[str] = None,
) -> str:
"""Stable identifier for the endpoint that mints ``reasoning.encrypted_content``.
Blobs are sealed to their issuer (replaying across endpoints yields HTTP 400
``invalid_encrypted_content``); stamping items with the issuer lets replay
drop foreign blobs after a mid-conversation model switch.
"""
if is_xai_responses:
return "xai_responses"
if is_github_responses:
return "github_responses"
if is_codex_backend:
return "codex_backend"
return f"other:{base_url}" if base_url else "other"
# Per-process throttle for the cross-issuer skip warning (long histories can
# carry many stale-issuer reasoning blocks).
_CROSS_ISSUER_WARN_EMITTED = False
# Codex/Harmony tool-call serialization leaked into assistant text when the
# model fails to emit a structured ``function_call`` (``to=functions.<name>``,
# optionally prefixed by ``assistant`` or a Harmony channel marker).
_TOOL_CALL_LEAK_PATTERN = re.compile(r"(?:^|[\s>|])to=functions\.[A-Za-z_][\w.]*", re.IGNORECASE)
# The Codex backend rejects requests containing these literal Harmony wire
# tokens (``invalid_prompt: Request blocked.``). Fullwidth bars survive
# format-character stripping while keeping the text legible.
_HARMONY_CONTROL_TOKEN_RE = re.compile(r"<\|(start|end|channel|message|constrain|return|call)\|>")
_FULLWIDTH_PIPE = "\uff5c"
_TEXT_PART_TYPES = {"text", "input_text", "output_text"}
_IMAGE_PART_TYPES = {"image_url", "input_image"}
_ASSISTANT_IMAGE_PLACEHOLDER = "[Assistant image omitted during replay]"
_INCOMPLETE_STATUSES = {"queued", "in_progress", "incomplete"}
_RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"}
# input[].id longer than this is a non-retryable 400 ("string too long").
# Codex-issued assistant message ids can run 400+ chars; Hermes-minted
# ``msg_...`` ids stay under the cap and are kept for prefix-cache hits.
# Function names share the same cap (same non-retryable 400).
_MAX_RESPONSES_ITEM_ID_LENGTH = 64
_VALID_RESPONSES_FN_NAME_RE = re.compile(r"[a-zA-Z0-9_-]{1,64}")
# Provider-executed built-in tools: declared on ``tools`` by ``type`` alone
# (no name/parameters) and run server-side, reporting via the ``*_call``
# output items below. Hermes injects xAI's ``web_search`` in
# agent/transports/codex.py; the rest are listed so preflight passes them
# through instead of rejecting them as "unsupported type".
_RESPONSES_BUILTIN_TOOL_TYPES = {
"web_search", "web_search_preview", "file_search", "code_interpreter",
"image_generation", "computer_use_preview", "local_shell",
}
# Server-side ``*_call`` output items. xAI routinely leaves these at
# ``status="in_progress"`` even when the response is ``completed`` (the search
# finished server-side; the per-item status is never reconciled), so they must
# NOT flip the incomplete verdict — otherwise every server-search turn burns 3
# fruitless continuation retries. Client-side function/custom tool calls keep
# their own in_progress handling.
_SERVER_SIDE_TOOL_CALL_TYPES = {
"web_search_call", "file_search_call", "code_interpreter_call",
"image_generation_call", "computer_call", "local_shell_call", "mcp_call",
}
def _nonblank(value: Any) -> TypeGuard[str]:
return isinstance(value, str) and bool(value.strip())
def _nonempty_str(value: Any) -> TypeGuard[str]:
return isinstance(value, str) and bool(value)
def _str_or_empty(value: Any) -> str:
return "" if value is None else str(value)
def _lower_or_none(value: Any) -> Optional[str]:
return value.strip().lower() if isinstance(value, str) else None
def _field(obj: Any, name: str, default: Any = None) -> Any:
"""Read ``name`` from a dict or an attribute-style (SDK/SimpleNamespace) object."""
return obj.get(name) if isinstance(obj, dict) else getattr(obj, name, default)
def _coerce_arguments(arguments: Any) -> str:
"""Normalize replayed tool-call arguments to a non-empty JSON string."""
if isinstance(arguments, dict):
arguments = json.dumps(arguments, ensure_ascii=False)
elif not isinstance(arguments, str):
arguments = str(arguments)
return arguments.strip() or "{}"
def _neutralize_harmony_tokens(text: str) -> str:
"""Keep Harmony source readable without emitting reserved wire tokens."""
if not text or "<" not in text or "|" not in text:
return text
replacement = rf"<{_FULLWIDTH_PIPE}\1{_FULLWIDTH_PIPE}>"
if not any(unicodedata.category(char) == "Cf" for char in text):
return _HARMONY_CONTROL_TOKEN_RE.sub(replacement, text)
# The backend strips Unicode format controls (e.g. U+200B) before its
# reserved-token check, so match on the visible text and rewrite the
# original spans — any Cf-hidden variant is neutralized the same way.
original_positions = [i for i, char in enumerate(text) if unicodedata.category(char) != "Cf"]
visible_text = "".join(text[i] for i in original_positions)
result: List[str] = []
cursor = 0
for match in _HARMONY_CONTROL_TOKEN_RE.finditer(visible_text):
start, end = original_positions[match.start()], original_positions[match.end() - 1] + 1
result += [text[cursor:start], f"<{_FULLWIDTH_PIPE}{match.group(1)}{_FULLWIDTH_PIPE}>"]
cursor = end
result.append(text[cursor:])
return "".join(result)
def _neutralize_harmony_structure(value: Any) -> Any:
"""Neutralize JSON-like values; normalize tuples to lists.
A reserved token in an object *key* is rejected rather than rewritten —
renaming a key could desynchronize a tool schema from the executor contract.
"""
if isinstance(value, str):
return _neutralize_harmony_tokens(value)
if isinstance(value, (list, tuple)):
return [_neutralize_harmony_structure(item) for item in value]
if isinstance(value, dict):
normalized = {}
for key, item in value.items():
if isinstance(key, str) and _neutralize_harmony_tokens(key) != key:
raise ValueError(
"Reserved Harmony tokens in a JSON object key cannot be "
"neutralized without changing its contract."
)
normalized[key] = _neutralize_harmony_structure(item)
return normalized
return value
# --- Multimodal content helpers ---------------------------------------------
def _iter_content_parts(content: list) -> Iterator[tuple[str, Any]]:
"""Yield ``("text", str)`` / ``("image", part)`` for recognized chat parts."""
for part in content:
if isinstance(part, str):
if part:
yield "text", part
elif isinstance(part, dict):
ptype = str(part.get("type") or "").strip().lower()
if ptype in _TEXT_PART_TYPES and _nonempty_str(part.get("text")):
yield "text", part["text"]
elif ptype in _IMAGE_PART_TYPES:
yield "image", part
def _resolve_image_ref(part: Dict[str, Any]) -> tuple[Any, Any]:
"""Return ``(url, detail)`` from either ``image_url: str`` or ``{url, detail}``."""
image_ref = part.get("image_url")
detail = part.get("detail")
if isinstance(image_ref, dict):
return image_ref.get("url"), image_ref.get("detail", detail)
return image_ref, detail
def _input_image_part(url: str, detail: Any) -> Dict[str, Any]:
image_part: Dict[str, Any] = {"type": "input_image", "image_url": url}
if _nonblank(detail):
image_part["detail"] = detail.strip()
return image_part
def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> List[Dict[str, Any]]:
"""Convert chat-style multimodal content to Responses API input parts.
Text becomes ``input_text`` (user) or ``output_text`` (assistant) — the API
rejects the wrong type per role, so callers MUST pass the right role.
``input_image`` is only legal on user messages; on assistant messages the
image is replaced by a text marker (an assistant ``input_image`` 400s on
every replay and bricks the session, and the wire cannot carry it anyway).
Returns an empty list when ``content`` is not a list or has no recognized
parts — callers fall back to the string path.
"""
text_type = "output_text" if role == "assistant" else "input_text"
converted: List[Dict[str, Any]] = []
for kind, payload in _iter_content_parts(content if isinstance(content, list) else []):
if kind == "text":
converted.append({"type": text_type, "text": payload})
elif role == "assistant":
converted.append({"type": "output_text", "text": _ASSISTANT_IMAGE_PLACEHOLDER})
else:
url, detail = _resolve_image_ref(payload)
if _nonempty_str(url):
converted.append(_input_image_part(url, detail))
return converted
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
"""Flatten message content to plain text.
Text parts are joined with ``sep`` (``" "`` for logs/spinner/trajectories;
``"\\n"`` for memory providers that feed the text to regexes and text APIs);
images become a ``[N image(s)]`` marker so the attachment is not erased.
Returns ``""`` for None/empty lists and ``str(content)`` for other scalars.
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = list(_iter_content_parts(content))
text_bits = [payload for kind, payload in parts if kind == "text"]
image_count = len(parts) - len(text_bits)
summary = sep.join(text_bits).strip()
if image_count:
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
summary = f"{note} {summary}" if summary else note
return summary
try:
return str(content)
except Exception:
return ""
# --- ID helpers ---------------------------------------------------------------
def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
"""Deterministic call_id (random ids would break the prompt-cache prefix).
Thin wrapper over ``agent.message_sanitization.deterministic_call_id``;
kept here because run_agent and tests import it from this module.
"""
return deterministic_call_id(fn_name, arguments, index)
def _clamp_responses_call_id(call_id: str) -> str:
"""Keep ``call_id`` within the API's 64-char cap.
The codex app-server namespaces MCP call ids (``codex_mcp__<server>__<tool>_
<id>``) past the cap, and the resulting 400 replays on every turn. The
surrogate is a pure function of the original so a ``function_call`` and its
``function_call_output`` map to the same value; short ids pass through.
"""
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 _sanitize_replayed_fn_name(name: str) -> str:
"""Coerce a *replayed* ``function_call.name`` to ``^[a-zA-Z0-9_-]{1,64}$``.
An invalid name stored in history (dots, spaces, unicode from a model
degeneration) otherwise 400s every later turn. Invalid runs collapse to
``_``; an all-invalid name degrades to ``"fn"`` rather than an empty string
(which would just trade the 400 for a preflight ValueError). Apply ONLY to
replayed items, never to live tool definitions (schema names must match the
dispatch registry); pairing with the output is by call_id, so renaming is safe.
"""
if not isinstance(name, str):
return "fn"
if _VALID_RESPONSES_FN_NAME_RE.fullmatch(name):
return name
coerced = re.sub(r"[^A-Za-z0-9_-]", "_", name.strip())
coerced = re.sub(r"_+", "_", coerced).strip("_")
return coerced[:64] or "fn"
def _canonical_call_id_from_fc(response_item_id: Any) -> Optional[str]:
"""Map an ``fc_…`` item id to its canonical ``call_<suffix>``.
Both sides of a replayed pair must derive the SAME call_id from an
fc_-only stored id, or an oversized pair clamps to two different
surrogates. Every caller must go through this helper.
"""
if isinstance(response_item_id, str) and response_item_id.startswith("fc_") and len(response_item_id) > 3:
return f"call_{response_item_id[3:]}"
return None
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
"""Split a stored tool id into (call_id, response_item_id)."""
value = raw_id.strip() if isinstance(raw_id, str) else ""
if not value:
return None, None
if "|" in value:
call_id, response_item_id = value.split("|", 1)
return call_id.strip() or None, response_item_id.strip() or None
return (None, value) if value.startswith("fc_") else (value, None)
def _resolve_call_id(
raw_call_id: Any, raw_item_id: Any, fn_name: str, arguments: Any, index: int, *, canonicalize_fc: bool,
) -> str:
"""Pick a non-blank call_id: explicit -> embedded in ``call|fc`` id -> (replay only)
canonical ``call_<fc suffix>`` -> deterministic hash of name/arguments/index."""
embedded_call_id, embedded_response_item_id = _split_responses_tool_id(raw_item_id)
call_id = raw_call_id if _nonblank(raw_call_id) else embedded_call_id
if not _nonblank(call_id) and canonicalize_fc:
call_id = _canonical_call_id_from_fc(embedded_response_item_id)
if not _nonblank(call_id):
call_id = _deterministic_call_id(fn_name, arguments, index)
return call_id.strip()
def _derive_responses_function_call_id(call_id: str, response_item_id: Optional[str] = None) -> str:
"""Build a valid Responses `function_call.id` (must start with `fc_`)."""
if isinstance(response_item_id, str) and response_item_id.strip().startswith("fc_"):
return response_item_id.strip()
source = (call_id or "").strip()
sanitized = re.sub(r"[^A-Za-z0-9_-]", "", source)
for candidate in (source, sanitized):
if candidate.startswith("fc_"):
return candidate
if candidate.startswith("call_") and len(candidate) > len("call_"):
return f"fc_{candidate[len('call_'):]}"
if sanitized:
return f"fc_{sanitized[:48]}"
seed = source or str(response_item_id or "") or uuid.uuid4().hex
return f"fc_{hashlib.sha1(seed.encode('utf-8')).hexdigest()[:24]}"
# --- Schema conversion --------------------------------------------------------
def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[List[Dict[str, Any]]]:
"""Convert chat-completions tool schemas to Responses function-tool schemas."""
if not tools:
return None
converted: List[Dict[str, Any]] = []
for item in tools:
fn = item.get("function", {}) if isinstance(item, dict) else {}
name = fn.get("name")
if not _nonblank(name):
continue
converted.append({
"type": "function", "name": name, "description": fn.get("description", ""), "strict": False,
"parameters": fn.get("parameters", {"type": "object", "properties": {}}),
})
return converted or None
# --- Message format conversion (chat history -> Responses input) --------------
def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str:
"""Normalize a replayed assistant message status (completed/incomplete/in_progress).
Preserved modulo case/hyphen spelling so incomplete Codex continuation
turns are not falsely marked completed.
"""
if isinstance(value, str):
status = value.strip().lower().replace("-", "_").replace(" ", "_")
if status in _RESPONSE_MESSAGE_STATUSES:
return status
return default
def _message_item(
content: List[Dict[str, Any]], *, status: str, item_id: Optional[str] = None, phase: Optional[str] = None,
) -> Dict[str, Any]:
"""Assistant ``message`` item; ``id``/``phase`` are added only when non-empty."""
item: Dict[str, Any] = {"type": "message", "role": "assistant", "status": status, "content": content}
if item_id:
item["id"] = item_id
if phase:
item["phase"] = phase
return item
def _assistant_message_item(
raw: Dict[str, Any], content: List[Dict[str, Any]], *, is_github_responses: bool,
) -> Dict[str, Any]:
"""Build a replayable assistant ``message`` item from a stored one.
``id`` is kept only when short enough (see _MAX_RESPONSES_ITEM_ID_LENGTH)
and never for GitHub Copilot, which binds ids to a backend connection and
401s on a stale one; ``phase`` is preserved per OpenAI's cache guidance.
"""
item_id, phase = raw.get("id"), raw.get("phase")
keep_id = not is_github_responses and _nonblank(item_id) and len(item_id.strip()) <= _MAX_RESPONSES_ITEM_ID_LENGTH
return _message_item(
content, status=_normalize_responses_message_status(raw.get("status")),
item_id=item_id.strip() if keep_id else None, phase=phase.strip() if _nonblank(phase) else None,
)
def _replay_reasoning_items(
msg: Dict[str, Any], *, seen_item_ids: set, current_issuer_kind: Optional[str], native_compaction_eligible: bool,
) -> List[Dict[str, Any]]:
"""Replay persisted encrypted reasoning/compaction items for one assistant turn.
Skips: duplicate ids; ``compaction`` checkpoints unless THIS request carries
``context_management`` (a persisted checkpoint outlives the gate and would
otherwise erase pre-checkpoint history on a model that cannot decrypt it);
items stamped by a different issuer (undecryptable → HTTP 400). Unstamped
legacy items pass through. ``id`` is stripped (store=False lookups 404) along
with the Hermes-only ``_issuer_kind`` stamp.
"""
global _CROSS_ISSUER_WARN_EMITTED
codex_reasoning = msg.get("codex_reasoning_items")
if not isinstance(codex_reasoning, list):
return []
replayed: List[Dict[str, Any]] = []
for ri in codex_reasoning:
if not (isinstance(ri, dict) and ri.get("encrypted_content")):
continue
item_id = ri.get("id")
if item_id and item_id in seen_item_ids:
continue
if ri.get("type") == "compaction" and not native_compaction_eligible:
continue
item_issuer = ri.get("_issuer_kind")
if current_issuer_kind is not None and item_issuer is not None and item_issuer != current_issuer_kind:
if not _CROSS_ISSUER_WARN_EMITTED:
logger.warning(
"Dropping reasoning item minted by %s while calling %s — encrypted_content is sealed to "
"its issuer. This happens when a session switches model providers mid-conversation.",
item_issuer, current_issuer_kind,
)
_CROSS_ISSUER_WARN_EMITTED = True
continue
replayed.append({k: v for k, v in ri.items() if k not in ("id", "_issuer_kind")})
if item_id:
seen_item_ids.add(item_id)
return replayed
def _replay_message_items(msg: Dict[str, Any], *, is_github_responses: bool) -> List[Dict[str, Any]]:
"""Replay exact assistant message items (id/phase) for prefix-cache hits."""
codex_message_items = msg.get("codex_message_items")
if not isinstance(codex_message_items, list):
return []
replayed: List[Dict[str, Any]] = []
for raw_item in codex_message_items:
if not (
isinstance(raw_item, dict) and raw_item.get("type") == "message"
and raw_item.get("role") == "assistant" and isinstance(raw_item.get("content"), list)
):
continue
content = [
{"type": "output_text", "text": _str_or_empty(part.get("text", ""))}
for part in raw_item["content"]
if isinstance(part, dict) and str(part.get("type") or "").strip() in {"output_text", "text"}
]
if content:
replayed.append(_assistant_message_item(raw_item, content, is_github_responses=is_github_responses))
return replayed
def _replay_tool_call_items(msg: Dict[str, Any], *, start_index: int) -> List[Dict[str, Any]]:
"""Convert an assistant message's ``tool_calls`` into ``function_call`` items."""
tool_calls = msg.get("tool_calls")
if not isinstance(tool_calls, list):
return []
replayed: List[Dict[str, Any]] = []
for tc in tool_calls:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {})
fn_name = fn.get("name")
if not _nonblank(fn_name):
continue
call_id = _resolve_call_id(
tc.get("call_id"), tc.get("id"), fn_name, str(fn.get("arguments", "{}")), start_index + len(replayed),
canonicalize_fc=True,
)
replayed.append({
"type": "function_call", "call_id": _clamp_responses_call_id(call_id),
"name": _sanitize_replayed_fn_name(fn_name), "arguments": _coerce_arguments(fn.get("arguments", "{}")),
})
return replayed
def _tool_output_item(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Convert a tool-role message to ``function_call_output`` (None if unpairable)."""
raw_tool_call_id = msg.get("tool_call_id")
call_id, tool_response_item_id = _split_responses_tool_id(raw_tool_call_id)
if not _nonblank(call_id):
# Legacy fc_-only ids canonicalize to the same ``call_<suffix>`` the
# assistant side synthesizes, so a >64-char pair clamps identically.
call_id = _canonical_call_id_from_fc(tool_response_item_id)
if call_id is None and _nonblank(raw_tool_call_id):
call_id = raw_tool_call_id.strip()
if not _nonblank(call_id):
return None
# ``output`` may be a string or an ``input_text``/``input_image`` array.
tool_content = msg.get("content")
output_value: Any = (
(_chat_content_to_responses_parts(tool_content, role="user") or "")
if isinstance(tool_content, list) else str(tool_content or "")
)
return {"type": "function_call_output", "call_id": _clamp_responses_call_id(call_id), "output": output_value}
def _chat_messages_to_responses_input(
messages: List[Dict[str, Any]],
*,
is_xai_responses: bool = False,
is_github_responses: bool = False,
replay_encrypted_reasoning: bool = True,
current_issuer_kind: Optional[str] = None,
native_compaction_eligible: bool = False,
) -> List[Dict[str, Any]]:
"""Convert internal chat-style messages to Responses input items.
``is_xai_responses``: kept for transport signature compatibility; encrypted
reasoning IS replayed on xAI (it relies on cross-turn reasoning threading).
``replay_encrypted_reasoning``: per-session kill switch. Relays that reject
a replayed blob with HTTP 400 ``invalid_encrypted_content`` trigger
``AIAgent._disable_codex_reasoning_replay``, which strips cached items and
threads False here so later turns send no reasoning items.
``is_github_responses``: drops ``id`` from replayed message items regardless
of length — Copilot binds ids to a backend connection and rejects a stale
one with HTTP 401 even for short ids. phase/status/content still replay.
``current_issuer_kind``: per-item cross-issuer guard (runs only while replay
is enabled); items stamped by another endpoint are dropped, unstamped legacy
items replay.
``native_compaction_eligible``: mirrors, for THIS request, whether
``native_compaction_context_management`` produced a payload. Gates both
replaying ``compaction`` checkpoints and restructuring the wire around them
(``prune_pre_checkpoint_items``). Checkpoints persist in the reasoning
sidecar across model swaps / compression flips / resume; without this gate
one captured checkpoint would delete pre-checkpoint history from every later
request on a model that cannot decrypt it. Default False = pre-feature wire,
correct for callers that never send ``context_management``. Dropping the
checkpoint is lossless: local history is never truncated by native compaction.
"""
items: List[Dict[str, Any]] = []
# Parallel to ``items``: the raw chat message each item came from. Pruning
# reads a summary carrier's up-to-date, provenance-tagged content from the
# source — the converted item may be a lossy shape (stale exact replay, or
# a typed ``function_call_output`` wrapper) that no longer carries it.
item_sources: List[Optional[Dict[str, Any]]] = []
seen_item_ids: set = set()
def emit(new_items: List[Dict[str, Any]], msg: Dict[str, Any]) -> None:
items.extend(new_items)
item_sources.extend([msg] * len(new_items))
for msg in messages:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role == "tool":
tool_item = _tool_output_item(msg)
if tool_item is not None:
emit([tool_item], msg)
continue
if role not in {"user", "assistant"}:
continue
content = msg.get("content", "")
content_parts = _chat_content_to_responses_parts(content, role=role) # [] unless a list
if isinstance(content, list):
text_type = "output_text" if role == "assistant" else "input_text"
content_text = "".join(p["text"] for p in content_parts if p["type"] == text_type)
else:
content_text = "" if content is None else str(content)
if role == "user":
emit([{"role": role, "content": content_parts or content_text}], msg)
continue
reasoning_items = [] if not replay_encrypted_reasoning else _replay_reasoning_items(
msg, seen_item_ids=seen_item_ids, current_issuer_kind=current_issuer_kind,
native_compaction_eligible=native_compaction_eligible,
)
emit(reasoning_items, msg)
message_items = _replay_message_items(msg, is_github_responses=is_github_responses)
emit(message_items, msg)
if not message_items:
if content_parts:
emit([{"role": "assistant", "content": content_parts}], msg)
elif content_text.strip():
emit([{"role": "assistant", "content": content_text}], msg)
elif reasoning_items:
# Every reasoning item needs a following item (else
# missing_following_item); emit an empty message.
emit([{"role": "assistant", "content": ""}], msg)
emit(_replay_tool_call_items(msg, start_index=len(items)), msg)
# Native server-side compaction: the server renders nothing placed before a
# compaction item, so pre-checkpoint history is dead upload weight and the
# user's plaintext asks / merged local summaries silently vanish. Keep the
# newest checkpoint first, retain pre-checkpoint USER and compression-SUMMARY
# messages verbatim within a token budget, leave the tail untouched. Gated
# on THIS request's eligibility, not merely on a checkpoint being present.
if not native_compaction_eligible:
return items
from agent.native_compaction import prune_pre_checkpoint_items
return prune_pre_checkpoint_items(items, item_sources=item_sources)
class ResponsesRouteFlags(NamedTuple):
"""Which special Responses-API route an agent is talking to.
Single owner of the codex/xai/github predicates: every site (request
kwargs, preflight estimation, silent-reject hints) must call
:func:`classify_responses_route` — inline string comparisons drift.
"""
is_codex_backend: bool
is_xai_responses: bool
is_github_responses: bool
def classify_responses_route(agent: Any) -> ResponsesRouteFlags:
"""Classify the agent's Responses route from provider + base URL.
Host checks are exact-host-or-subdomain, never substring —
``https://evil.com/models.github.ai`` must not classify as GitHub.
"""
from utils import base_url_hostname
provider = getattr(agent, "provider", None)
base_url = str(getattr(agent, "base_url", "") or "")
hostname = str(getattr(agent, "_base_url_hostname", "") or "").lower()
if not hostname:
hostname = base_url_hostname(base_url)
lower = str(getattr(agent, "_base_url_lower", "") or base_url).lower()
def _host_is(domain: str) -> bool:
return hostname == domain or hostname.endswith("." + domain)
return ResponsesRouteFlags(
is_codex_backend=(
provider == "openai-codex" or (_host_is("chatgpt.com") and "/backend-api/codex" in lower)
),
is_xai_responses=provider in {"xai", "xai-oauth"} or hostname == "api.x.ai",
is_github_responses=_host_is("models.github.ai") or _host_is("githubcopilot.com"),
)
def estimate_native_responses_preflight_tokens(
agent: Any,
messages: List[Dict[str, Any]],
*,
system_prompt: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
) -> Optional[int]:
"""Estimate tokens for the checkpoint-pruned Responses payload.
Counting the full durable transcript overstates a natively compacted
session several times over and fires local compression against history
the request will never send. Returns None when native compaction is not
proven eligible or conversion fails — caller falls back to the generic
(conservative) estimate.
"""
if getattr(agent, "api_mode", None) != "codex_responses" or not isinstance(messages, list):
return None
is_codex_backend, is_xai_responses, is_github_responses = classify_responses_route(agent)
from agent.native_compaction import native_compaction_context_management
if not native_compaction_context_management(
agent, is_codex_backend=is_codex_backend, is_xai_responses=is_xai_responses,
is_github_responses=is_github_responses,
):
return None
try:
items = _chat_messages_to_responses_input(
messages, is_xai_responses=is_xai_responses, is_github_responses=is_github_responses,
replay_encrypted_reasoning=bool(getattr(agent, "_codex_reasoning_replay_enabled", True)),
current_issuer_kind=_classify_responses_issuer(
is_xai_responses=is_xai_responses, is_github_responses=is_github_responses,
is_codex_backend=is_codex_backend, base_url=getattr(agent, "base_url", None),
),
native_compaction_eligible=True,
)
except Exception:
logger.debug("native Responses preflight conversion failed; falling back to generic estimate", exc_info=True)
return None
if not isinstance(items, list):
return None
from agent.model_metadata import estimate_request_tokens_rough
return estimate_request_tokens_rough(items, system_prompt=system_prompt or "", tools=tools)
# --- Input preflight / validation --------------------------------------------
class _PreflightCtx(NamedTuple):
sanitize_text: Callable[[str], str]
sanitize_harmony_tokens: bool
is_github_responses: bool
seen_ids: set
def _preflight_function_call(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Dict[str, Any]:
call_id = item.get("call_id")
name = item.get("name")
if not _nonblank(call_id):
raise ValueError(f"Codex Responses input[{idx}] function_call is missing call_id.")
if not _nonblank(name):
raise ValueError(f"Codex Responses input[{idx}] function_call is missing name.")
return {
"type": "function_call", "call_id": call_id.strip(), "name": _sanitize_replayed_fn_name(name),
"arguments": ctx.sanitize_text(_coerce_arguments(item.get("arguments", "{}"))),
}
def _preflight_function_call_output(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Dict[str, Any]:
call_id = item.get("call_id")
if not _nonblank(call_id):
raise ValueError(f"Codex Responses input[{idx}] function_call_output is missing call_id.")
output = item.get("output", "")
if isinstance(output, list):
# Multimodal tool result: keep recognised input_text/input_image
# parts, drop anything else to avoid a 4xx.
cleaned: List[Dict[str, Any]] = []
for part in output:
ptype = part.get("type") if isinstance(part, dict) else None
if ptype == "input_text" and _nonempty_str(part.get("text")):
cleaned.append({"type": "input_text", "text": ctx.sanitize_text(part["text"])})
elif ptype == "input_image" and _nonempty_str(part.get("image_url")):
cleaned.append(_input_image_part(part["image_url"], part.get("detail")))
output_value: Any = cleaned or ""
else:
output_value = ctx.sanitize_text(_str_or_empty(output))
return {"type": "function_call_output", "call_id": call_id.strip(), "output": output_value}
def _preflight_reasoning(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Optional[Dict[str, Any]]:
encrypted = item.get("encrypted_content")
if not _nonempty_str(encrypted):
return None
# ``id`` is used only for local dedup and NOT forwarded: with store=False
# the API resolves ids server-side and 404s.
item_id = item.get("id")
if _nonempty_str(item_id):
if item_id in ctx.seen_ids:
return None
ctx.seen_ids.add(item_id)
summary = item.get("summary")
if not isinstance(summary, list):
summary = []
return {
"type": "reasoning", "encrypted_content": encrypted,
"summary": _neutralize_harmony_structure(summary) if ctx.sanitize_harmony_tokens else summary,
}
def _preflight_compaction(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Optional[Dict[str, Any]]:
# Opaque, issuer-sealed checkpoint; forward only the fields the API defines.
encrypted = item.get("encrypted_content")
return {"type": "compaction", "encrypted_content": encrypted} if _nonempty_str(encrypted) else None
def _preflight_message(item: Dict[str, Any], idx: int, ctx: _PreflightCtx) -> Dict[str, Any]:
if item.get("role") != "assistant":
raise ValueError(f"Codex Responses input[{idx}] message items must have role='assistant'.")
content = item.get("content")
if not isinstance(content, list):
raise ValueError(f"Codex Responses input[{idx}] message item must have content list.")
normalized_content = []
for part_idx, part in enumerate(content):
if not isinstance(part, dict):
raise ValueError(f"Codex Responses input[{idx}] message content[{part_idx}] must be an object.")
part_type = part.get("type")
if part_type not in {"output_text", "text"}:
raise ValueError(
f"Codex Responses input[{idx}] message content[{part_idx}] has unsupported type {part_type!r}."
)
text = _str_or_empty(part.get("text", ""))
normalized_content.append({"type": "output_text", "text": ctx.sanitize_text(text)})
if not normalized_content:
raise ValueError(f"Codex Responses input[{idx}] message item must contain at least one text part.")
return _assistant_message_item(item, normalized_content, is_github_responses=ctx.is_github_responses)
def _preflight_role_message(item: Dict[str, Any], idx: int, role: str, ctx: _PreflightCtx) -> Dict[str, Any]:
content = item.get("content", "")
if not isinstance(content, list):
return {"role": role, "content": ctx.sanitize_text(_str_or_empty(content))}
# Parts are already Responses-shaped; validate and re-type text for the
# role (``output_text`` for assistant, ``input_text`` for user). Unlike
# history conversion, empty text / empty image urls are kept, not dropped.
text_type = "output_text" if role == "assistant" else "input_text"
validated: List[Dict[str, Any]] = []
for part_idx, part in enumerate(content):
if isinstance(part, str):
if part:
validated.append({"type": text_type, "text": ctx.sanitize_text(part)})
continue
if not isinstance(part, dict):
raise ValueError(f"Codex Responses input[{idx}].content[{part_idx}] must be an object or string.")
ptype = str(part.get("type") or "").strip().lower()
if ptype in _TEXT_PART_TYPES:
text = part.get("text", "")
text = text if isinstance(text, str) else str(text or "")
validated.append({"type": text_type, "text": ctx.sanitize_text(text)})
elif ptype in _IMAGE_PART_TYPES:
if role == "assistant":
# Same output-message invariant as normal history replay.
validated.append({"type": "output_text", "text": _ASSISTANT_IMAGE_PLACEHOLDER})
else:
url, detail = _resolve_image_ref(part)
validated.append(_input_image_part(url if isinstance(url, str) else str(url or ""), detail))
else:
raise ValueError(
f"Codex Responses input[{idx}].content[{part_idx}] has unsupported type {part.get('type')!r}."
)
return {"role": role, "content": validated}
_PREFLIGHT_ITEM_HANDLERS: Dict[str, Callable[..., Optional[Dict[str, Any]]]] = {
"function_call": _preflight_function_call,
"function_call_output": _preflight_function_call_output,
"reasoning": _preflight_reasoning,
"compaction": _preflight_compaction,
"message": _preflight_message,
}
def _preflight_codex_input_items(
raw_items: Any, *, is_github_responses: bool = False, sanitize_harmony_tokens: bool = False,
) -> List[Dict[str, Any]]:
if not isinstance(raw_items, list):
raise ValueError("Codex Responses input must be a list of input items.")
ctx = _PreflightCtx(
sanitize_text=_neutralize_harmony_tokens if sanitize_harmony_tokens else (lambda text: text),
sanitize_harmony_tokens=sanitize_harmony_tokens,
is_github_responses=is_github_responses,
seen_ids=set(),
)
normalized: List[Dict[str, Any]] = []
for idx, item in enumerate(raw_items):
if not isinstance(item, dict):
raise ValueError(f"Codex Responses input[{idx}] must be an object.")
item_type = item.get("type")
handler = _PREFLIGHT_ITEM_HANDLERS.get(item_type) if isinstance(item_type, str) else None
if handler is not None:
normalized_item = handler(item, idx, ctx)
else:
# Untyped role messages (user/assistant) are the only other legal shape.
role = item.get("role")
if role not in {"user", "assistant"}:
raise ValueError(
f"Codex Responses input[{idx}] has unsupported item shape (type={item_type!r}, role={role!r})."
)
normalized_item = _preflight_role_message(item, idx, role, ctx)
if normalized_item is not None:
normalized.append(normalized_item)
return normalized
def _preflight_tool(tool: Any, idx: int) -> Dict[str, Any]:
if not isinstance(tool, dict):
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
tool_type = tool.get("type")
# Provider-executed built-ins carry no name/parameters; pass them through
# verbatim rather than rejecting them below.
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
return dict(tool)
if tool_type != "function":
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
name = tool.get("name")
parameters = tool.get("parameters")
if not _nonblank(name):
raise ValueError(f"Codex Responses tools[{idx}] is missing a valid name.")
if not isinstance(parameters, dict):
raise ValueError(f"Codex Responses tools[{idx}] is missing valid parameters.")
return {
"type": "function", "name": name.strip(), "description": _str_or_empty(tool.get("description", "")),
"strict": bool(tool.get("strict", False)), "parameters": parameters,
}
# Optional scalar request fields, in wire order: (key, accept(value), coerce).
# Values failing ``accept`` are silently dropped (never an error).
_PREFLIGHT_OPTIONAL_FIELDS: tuple[tuple[str, Callable[[Any], bool], Optional[Callable[[Any], Any]]], ...] = (
("reasoning", lambda v: isinstance(v, dict), None),
("include", lambda v: isinstance(v, list), None),
("service_tier", _nonblank, str.strip),
("max_output_tokens", lambda v: isinstance(v, (int, float)) and v > 0, int),
("timeout", lambda v: isinstance(v, (int, float)) and not isinstance(v, bool) and 0 < v < float("inf"), float),
("temperature", lambda v: isinstance(v, (int, float)), float),
# Cache routing/retention and tool-dispatch hints pass through as-is.
("tool_choice", lambda v: v is not None, None),
("parallel_tool_calls", lambda v: v is not None, None),
("prompt_cache_key", lambda v: v is not None, None),
("prompt_cache_retention", lambda v: v is not None, None),
# Native compaction directive; eligibility is resolved upstream in
# agent/native_compaction.py — preflight only preserves the shape.
("context_management", lambda v: isinstance(v, list) and bool(v), None),
)
_PREFLIGHT_ALLOWED_KEYS = {
"model", "instructions", "input", "tools", "store", "extra_headers", "extra_body",
*(key for key, _, _ in _PREFLIGHT_OPTIONAL_FIELDS),
}
def _preflight_codex_api_kwargs(
api_kwargs: Any,
*,
allow_stream: bool = False,
is_github_responses: bool = False,
sanitize_harmony_tokens: bool = False,
) -> Dict[str, Any]:
if not isinstance(api_kwargs, dict):
raise ValueError("Codex Responses request must be a dict.")
missing = [key for key in ("model", "instructions", "input") if key not in api_kwargs]
if missing:
raise ValueError(f"Codex Responses request missing required field(s): {', '.join(sorted(missing))}.")
model = api_kwargs.get("model")
if not _nonblank(model):
raise ValueError("Codex Responses request 'model' must be a non-empty string.")
instructions = _str_or_empty(api_kwargs.get("instructions")).strip() or DEFAULT_AGENT_IDENTITY
if sanitize_harmony_tokens:
instructions = _neutralize_harmony_tokens(instructions)
normalized: Dict[str, Any] = {
"model": model.strip(),
"instructions": instructions,
"input": _preflight_codex_input_items(
api_kwargs.get("input"),
is_github_responses=is_github_responses,
sanitize_harmony_tokens=sanitize_harmony_tokens,
),
"store": False,
}
tools = api_kwargs.get("tools")
if tools is not None:
if not isinstance(tools, list):
raise ValueError("Codex Responses request 'tools' must be a list when provided.")
normalized_tools = [_preflight_tool(tool, idx) for idx, tool in enumerate(tools)]
if sanitize_harmony_tokens:
normalized_tools = _neutralize_harmony_structure(normalized_tools)
normalized["tools"] = normalized_tools
if api_kwargs.get("store", False) is not False:
raise ValueError("Codex Responses contract requires 'store' to be false.")
for key, accept, coerce in _PREFLIGHT_OPTIONAL_FIELDS:
value = api_kwargs.get(key)
if accept(value):
normalized[key] = coerce(value) if coerce else value
extra_headers = api_kwargs.get("extra_headers")
if extra_headers is not None:
if not isinstance(extra_headers, dict):
raise ValueError("Codex Responses request 'extra_headers' must be an object.")
if not all(_nonblank(key) for key in extra_headers):
raise ValueError("Codex Responses request 'extra_headers' keys must be non-empty strings.")
normalized_headers = {key.strip(): str(value) for key, value in extra_headers.items() if value is not None}
if normalized_headers:
normalized["extra_headers"] = normalized_headers
extra_body = api_kwargs.get("extra_body")
if extra_body is not None:
if not isinstance(extra_body, dict):
raise ValueError("Codex Responses request 'extra_body' must be an object.")
# Verbatim: xAI carries ``prompt_cache_key`` as a body-level field. The
# SDK serializes extra_body without per-field checks, so it survives
# Responses.stream() kwarg-signature changes.
if extra_body:
normalized["extra_body"] = dict(extra_body)
allowed_keys = set(_PREFLIGHT_ALLOWED_KEYS)
if allow_stream:
stream = api_kwargs.get("stream")
if stream is not None and stream is not True:
raise ValueError("Codex Responses 'stream' must be true when set.")
if stream is True:
normalized["stream"] = True
allowed_keys.add("stream")
elif "stream" in api_kwargs:
raise ValueError("Codex Responses stream flag is only allowed in fallback streaming requests.")
# Defense-in-depth slash-enum strip for xAI (rejects ``Qwen/Qwen3.5`` style
# enum values with "Invalid arguments passed to the model"). Gated on the
# model name because native Codex accepts slashes — stripping there would
# silently degrade tool-schema constraints.
is_xai_model = str(api_kwargs.get("model") or "").lower().startswith(("grok-", "x-ai/grok-"))
if is_xai_model and normalized.get("tools"):
try:
from tools.schema_sanitizer import strip_slash_enum
normalized["tools"], _ = strip_slash_enum(normalized["tools"])
except Exception:
pass # Best-effort — the caller-level sanitization should have handled it
unexpected = sorted(key for key in api_kwargs if key not in allowed_keys)
if unexpected:
raise ValueError(f"Codex Responses request has unsupported field(s): {', '.join(unexpected)}.")
return normalized
# --- Response extraction helpers ----------------------------------------------
def _text_chunks(parts: Any, types: Optional[set] = None) -> List[str]:
"""Non-empty ``.text`` of each part (optionally filtered by ``.type``); [] if not a list."""
if not isinstance(parts, list):
return []
return [
text for text in (
getattr(part, "text", None) for part in parts if types is None or getattr(part, "type", None) in types
)
if _nonempty_str(text)
]
def _extract_responses_message_text(item: Any) -> str:
"""Extract assistant text from a Responses message output item."""
return "".join(_text_chunks(getattr(item, "content", None), {"output_text", "text"})).strip()
def _extract_responses_reasoning_text(item: Any) -> str:
"""Extract a compact reasoning text from a Responses reasoning item (summary, else ``text``)."""
chunks = _text_chunks(getattr(item, "summary", None))
text = getattr(item, "text", None)
return "\n".join(chunks).strip() if chunks else (text.strip() if isinstance(text, str) else "")
def _format_responses_error(error_obj: Any, response_status: str) -> str:
"""Human-readable string for a ``response.error`` payload (dict or object).
Prefers ``"<code>: <message>"`` so failure modes (rate limit vs context
length vs overloaded) are distinguishable; falls back to whichever is
present, then ``str(error_obj)``, then a status-based default.
"""
def field(name: str) -> str:
value = _field(error_obj, name)
return str(value).strip() if isinstance(value, str) or value else ""
code_str, message_str = field("code"), field("message")
if code_str and message_str:
return f"{code_str}: {message_str}"
if message_str or code_str or error_obj:
return message_str or code_str or str(error_obj)
return f"Responses API returned status '{response_status}'"
# --- Full response normalization ----------------------------------------------
def _synthetic_message(content: List[Any]) -> SimpleNamespace:
return SimpleNamespace(type="message", role="assistant", status="completed", content=content)
def _response_tool_call(item: Any, item_type: str, index: int) -> SimpleNamespace:
"""Build a chat-style tool_call from a ``function_call``/``custom_tool_call`` item."""
fn_name = getattr(item, "name", "") or ""
arguments = getattr(item, "arguments" if item_type == "function_call" else "input", "{}")
if not isinstance(arguments, str):
arguments = json.dumps(arguments, ensure_ascii=False)
raw_item_id = getattr(item, "id", None)
call_id = _resolve_call_id(
getattr(item, "call_id", None), raw_item_id, fn_name, arguments, index, canonicalize_fc=False,
)
fc_id = _derive_responses_function_call_id(call_id, raw_item_id if isinstance(raw_item_id, str) else None)
return SimpleNamespace(
id=call_id, call_id=call_id, response_item_id=fc_id, type="function",
function=SimpleNamespace(name=fn_name, arguments=arguments),
)
def _stamped_encrypted_item(item: Any, item_type: str, issuer_kind: Optional[str]) -> Optional[Dict[str, Any]]:
"""``{type, encrypted_content[, _issuer_kind]}`` for replay, or None without a blob.
``_issuer_kind`` is stamped so a later model swap can detect an endpoint
that cannot decrypt the blob.
"""
encrypted = getattr(item, "encrypted_content", None)
if not _nonempty_str(encrypted):
return None
raw_item: Dict[str, Any] = {"type": item_type, "encrypted_content": encrypted}
if issuer_kind:
raw_item["_issuer_kind"] = issuer_kind
return raw_item
def _capture_reasoning_item(item: Any, issuer_kind: Optional[str]) -> Optional[Dict[str, Any]]:
"""Capture a reasoning item (blob + summary) for replay; transient ``rs_tmp_`` items are skipped."""
raw_item = _stamped_encrypted_item(item, "reasoning", issuer_kind)
if raw_item is None:
return None
item_id = getattr(item, "id", None)
if isinstance(item_id, str) and item_id.startswith("rs_tmp_"):
logger.debug("Skipping transient Codex reasoning item during normalization: %s", item_id)
return None
if _nonempty_str(item_id):
raw_item["id"] = item_id
# Summary is required by the API when replaying reasoning items.
summary = getattr(item, "summary", None)
if isinstance(summary, list):
raw_item["summary"] = [
{"type": "summary_text", "text": text}
for text in (getattr(part, "text", None) for part in summary)
if isinstance(text, str)
]
return raw_item
def _normalize_codex_response(response: Any, *, issuer_kind: Optional[str] = None) -> tuple[Any, str]:
"""Normalize a Responses API object to an assistant_message-like object.
``issuer_kind`` is stamped onto captured reasoning items so replay can drop
them once the active endpoint differs from the one that minted the blob.
"""
response_status = _lower_or_none(getattr(response, "status", None))
incomplete_reason = _field(getattr(response, "incomplete_details", None), "reason", "")
response_incomplete_content_filter = (
response_status == "incomplete" and str(incomplete_reason or "").strip().lower() == "content_filter"
)
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
# Codex can deliver the whole answer via stream events and return an
# empty output; fall back to output_text before raising.
out_text = getattr(response, "output_text", None)
if isinstance(out_text, str) and out_text.strip():
logger.debug(
"Codex response has empty output but output_text is present (%d chars); synthesizing output item.",
len(out_text.strip()),
)
output = [_synthetic_message([SimpleNamespace(type="output_text", text=out_text.strip())])]
elif response_incomplete_content_filter:
# Deterministic provider safety block, not a partial answer:
# synthesize an empty message so finish_reason becomes
# content_filter instead of burning continuation attempts.
output = [_synthetic_message([])]
else:
raise RuntimeError("Responses API returned no output items")
response.output = output
if response_status in {"failed", "cancelled"}:
raise RuntimeError(_format_responses_error(getattr(response, "error", None), response_status))
content_parts: List[str] = []
reasoning_parts: List[str] = []
reasoning_items_raw: List[Dict[str, Any]] = []
message_items_raw: List[Dict[str, Any]] = []
tool_calls: List[Any] = []
has_incomplete_items = response_status in _INCOMPLETE_STATUSES
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
saw_commentary_phase = False
saw_final_answer_phase = False
saw_reasoning_item = False
for item in output:
item_type = getattr(item, "type", None)
item_status = _lower_or_none(getattr(item, "status", None))
if item_status in _INCOMPLETE_STATUSES and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES:
has_incomplete_items = True
saw_streaming_or_item_incomplete = True
if item_type == "message":
normalized_phase = _lower_or_none(getattr(item, "phase", None))
is_commentary_phase = normalized_phase in {"commentary", "analysis"}
saw_commentary_phase = saw_commentary_phase or is_commentary_phase
saw_final_answer_phase = saw_final_answer_phase or normalized_phase in {"final_answer", "final"}
message_text = _extract_responses_message_text(item)
if message_text:
# commentary/analysis phase text is mid-turn narration, never
# the final answer: keep it out of content (so it cannot leak
# into the response) but surface it via the reasoning channel.
# The exact item is still preserved for replay/cache continuity.
(reasoning_parts if is_commentary_phase else content_parts).append(message_text)
item_id = getattr(item, "id", None)
message_items_raw.append(_message_item(
[{"type": "output_text", "text": message_text}],
status=_normalize_responses_message_status(item_status),
item_id=item_id if isinstance(item_id, str) else None, phase=normalized_phase,
))
elif item_type == "reasoning":
saw_reasoning_item = True
reasoning_text = _extract_responses_reasoning_text(item)
if reasoning_text:
reasoning_parts.append(reasoning_text)
raw_item = _capture_reasoning_item(item, issuer_kind)
if raw_item is not None:
reasoning_items_raw.append(raw_item)
elif item_type == "compaction":
# Native compaction checkpoint: rides the codex_reasoning_items
# sidecar so it inherits persistence, replay, the cross-issuer
# guard and the kill switch without new state.
raw_item = _stamped_encrypted_item(item, "compaction", issuer_kind)
if raw_item is not None:
reasoning_items_raw.append(raw_item)
logger.info(
"Native Responses compaction item captured (%d chars encrypted).",
len(raw_item["encrypted_content"]),
)
elif item_type in {"function_call", "custom_tool_call"}:
if item_type == "function_call" and item_status in _INCOMPLETE_STATUSES:
continue
tool_calls.append(_response_tool_call(item, item_type, len(tool_calls)))
final_text = "\n".join(content_parts).strip()
if not final_text and hasattr(response, "output_text") and (saw_final_answer_phase or not saw_commentary_phase):
out_text = getattr(response, "output_text", "")
if isinstance(out_text, str):
final_text = out_text.strip()
# Tool-call leak recovery: gpt-5.x sometimes emits the intended
# ``function_call`` as plain Harmony text (``to=functions.foo {json}``)
# with no structured item, so ``tool_calls`` is empty and no tool ran.
# Treat as incomplete so the continuation path re-elicits a real call;
# clear the text so the garbage is not surfaced as a summary (encrypted
# reasoning is preserved for the retry).
leaked_tool_call_text = bool(final_text and not tool_calls and _TOOL_CALL_LEAK_PATTERN.search(final_text))
if leaked_tool_call_text:
logger.warning(
"Codex response contains leaked tool-call text in assistant content (no structured function_call "
"items). Treating as incomplete so the continuation path can re-elicit a proper tool call. "
"Leaked snippet: %r", final_text[:300],
)
final_text = ""
# Reasoning-channel answer salvage (xAI grok): grok-4.x sometimes puts the
# final answer inside the reasoning item after its ``<response>`` delimiter.
# Without salvage the reasoning-only rule marks the turn incomplete, and
# since these items carry no encrypted_content every continuation request
# is byte-identical to the failed one. Promote the delimited tail to
# content and keep the untagged prefix as thinking text.
if issuer_kind == "xai_responses" and not final_text and not tool_calls and reasoning_parts:
joined_reasoning = "\n\n".join(reasoning_parts)
marker = joined_reasoning.rfind("<response>")
if marker != -1:
salvaged = joined_reasoning[marker + len("<response>"):].split("</response>", 1)[0].strip()
if salvaged:
logger.warning(
"xAI response delivered its final answer inside the reasoning channel "
"(<response> delimiter); promoting %d chars to assistant content.", len(salvaged),
)
final_text = salvaged
reasoning_prefix = joined_reasoning[:marker].strip()
reasoning_parts = [reasoning_prefix] if reasoning_prefix else []
assistant_message = SimpleNamespace(
content=final_text,
tool_calls=tool_calls,
reasoning="\n\n".join(reasoning_parts).strip() if reasoning_parts else None,
reasoning_content=None,
reasoning_details=None,
codex_reasoning_items=reasoning_items_raw or None,
codex_message_items=message_items_raw or None,
)
if tool_calls:
finish_reason = "tool_calls"
elif response_incomplete_content_filter:
finish_reason = "content_filter"
elif (
leaked_tool_call_text
or saw_streaming_or_item_incomplete
or ((has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase)
):
finish_reason = "incomplete"
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
# Reasoning-only response. For Codex/xAI/GitHub, reasoning-only with
# status=completed means "still thinking, needs another turn" →
# incomplete so the continuation path retries. Other backends: trust
# response.status — forcing incomplete there stalls for minutes
# (3 retries × up to 240s) on a legitimately final state.
trusted_final = (
response_status == "completed" and issuer_kind not in ("codex_backend", "xai_responses", "github_responses")
)
finish_reason = "stop" if trusted_final else "incomplete"
else:
finish_reason = "stop"
return assistant_message, finish_reason