2026-04-20 05:17:44 -07:00
|
|
|
"""Codex Responses API adapter.
|
|
|
|
|
|
|
|
|
|
Pure format-conversion and normalization logic for the OpenAI Responses API
|
|
|
|
|
(used by OpenAI Codex, xAI, GitHub Models, and other Responses-compatible endpoints).
|
|
|
|
|
|
|
|
|
|
Extracted from run_agent.py to isolate Responses API-specific logic from the
|
|
|
|
|
core agent loop. All functions are stateless — they operate on the data passed
|
|
|
|
|
in and return transformed results.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import re
|
|
|
|
|
import uuid
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 02:30:43 -07:00
|
|
|
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 Responses endpoint that mints encrypted_content.
|
|
|
|
|
|
|
|
|
|
``reasoning.encrypted_content`` is sealed to the endpoint that issued it:
|
|
|
|
|
replaying a Codex-minted blob against xAI (or vice versa) deterministically
|
|
|
|
|
returns HTTP 400 ``invalid_encrypted_content``. Stamping the issuer on
|
|
|
|
|
persisted reasoning items and filtering at replay time lets a single
|
|
|
|
|
conversation switch models without poisoning history with un-decryptable
|
|
|
|
|
reasoning blocks.
|
|
|
|
|
"""
|
|
|
|
|
if is_xai_responses:
|
|
|
|
|
return "xai_responses"
|
|
|
|
|
if is_github_responses:
|
|
|
|
|
return "github_responses"
|
|
|
|
|
if is_codex_backend:
|
|
|
|
|
return "codex_backend"
|
|
|
|
|
if base_url:
|
|
|
|
|
return f"other:{base_url}"
|
|
|
|
|
return "other"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Throttle the per-process cross-issuer skip warning so we don't flood logs
|
|
|
|
|
# when a long history contains many stale-issuer reasoning blocks.
|
|
|
|
|
_CROSS_ISSUER_WARN_EMITTED = False
|
|
|
|
|
|
|
|
|
|
|
2026-04-24 14:39:59 -07:00
|
|
|
# Matches Codex/Harmony tool-call serialization that occasionally leaks into
|
|
|
|
|
# assistant-message content when the model fails to emit a structured
|
|
|
|
|
# ``function_call`` item. Accepts the common forms:
|
|
|
|
|
#
|
|
|
|
|
# to=functions.exec_command
|
|
|
|
|
# assistant to=functions.exec_command
|
|
|
|
|
# <|channel|>commentary to=functions.exec_command
|
|
|
|
|
#
|
|
|
|
|
# ``to=functions.<name>`` is the stable marker — the optional ``assistant`` or
|
|
|
|
|
# Harmony channel prefix varies by degeneration mode. Case-insensitive to
|
|
|
|
|
# cover lowercase/uppercase ``assistant`` variants.
|
|
|
|
|
_TOOL_CALL_LEAK_PATTERN = re.compile(
|
|
|
|
|
r"(?:^|[\s>|])to=functions\.[A-Za-z_][\w.]*",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Multimodal content helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-25 10:13:29 -07:00
|
|
|
def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> List[Dict[str, Any]]:
|
2026-04-20 05:17:44 -07:00
|
|
|
"""Convert chat-style multimodal content to Responses API input parts.
|
|
|
|
|
|
|
|
|
|
Input: ``[{"type":"text"|"image_url", ...}]`` (native OpenAI Chat format)
|
2026-04-25 10:13:29 -07:00
|
|
|
Output: ``[{"type":"input_text"|"output_text"|"input_image", ...}]`` (Responses format)
|
|
|
|
|
|
|
|
|
|
The ``role`` parameter controls the text content type:
|
|
|
|
|
- ``"user"`` (default) → ``"input_text"``
|
|
|
|
|
- ``"assistant"`` → ``"output_text"``
|
|
|
|
|
|
|
|
|
|
The Responses API rejects ``input_text`` inside assistant messages and
|
|
|
|
|
``output_text`` inside user messages, so callers MUST pass the correct
|
|
|
|
|
role for the message being converted.
|
2026-04-20 05:17:44 -07:00
|
|
|
|
|
|
|
|
Returns an empty list when ``content`` is not a list or contains no
|
|
|
|
|
recognized parts — callers fall back to the string path.
|
|
|
|
|
"""
|
2026-04-25 10:13:29 -07:00
|
|
|
text_type = "output_text" if role == "assistant" else "input_text"
|
2026-04-20 05:17:44 -07:00
|
|
|
if not isinstance(content, list):
|
|
|
|
|
return []
|
|
|
|
|
converted: List[Dict[str, Any]] = []
|
|
|
|
|
for part in content:
|
|
|
|
|
if isinstance(part, str):
|
|
|
|
|
if part:
|
2026-04-25 10:13:29 -07:00
|
|
|
converted.append({"type": text_type, "text": part})
|
2026-04-20 05:17:44 -07:00
|
|
|
continue
|
|
|
|
|
if not isinstance(part, dict):
|
|
|
|
|
continue
|
|
|
|
|
ptype = str(part.get("type") or "").strip().lower()
|
|
|
|
|
if ptype in {"text", "input_text", "output_text"}:
|
|
|
|
|
text = part.get("text")
|
|
|
|
|
if isinstance(text, str) and text:
|
2026-04-25 10:13:29 -07:00
|
|
|
converted.append({"type": text_type, "text": text})
|
2026-04-20 05:17:44 -07:00
|
|
|
continue
|
|
|
|
|
if ptype in {"image_url", "input_image"}:
|
|
|
|
|
image_ref = part.get("image_url")
|
|
|
|
|
detail = part.get("detail")
|
|
|
|
|
if isinstance(image_ref, dict):
|
|
|
|
|
url = image_ref.get("url")
|
|
|
|
|
detail = image_ref.get("detail", detail)
|
|
|
|
|
else:
|
|
|
|
|
url = image_ref
|
|
|
|
|
if not isinstance(url, str) or not url:
|
|
|
|
|
continue
|
|
|
|
|
image_part: Dict[str, Any] = {"type": "input_image", "image_url": url}
|
|
|
|
|
if isinstance(detail, str) and detail.strip():
|
|
|
|
|
image_part["detail"] = detail.strip()
|
|
|
|
|
converted.append(image_part)
|
|
|
|
|
return converted
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 12:49:18 +05:30
|
|
|
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
|
|
|
|
|
"""Flatten message content to a plain-text summary.
|
2026-04-20 05:17:44 -07:00
|
|
|
|
|
|
|
|
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
|
2026-06-12 12:49:18 +05:30
|
|
|
parts from the API server. Several consumers want a plain string:
|
|
|
|
|
|
|
|
|
|
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
|
|
|
|
|
- External memory providers, which feed the text to regexes
|
|
|
|
|
(``sanitize_context``) and text APIs — a raw list crashes the sync with
|
|
|
|
|
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
|
|
|
|
|
|
|
|
|
|
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
|
|
|
|
|
so the turn isn't recorded as if the attachment never existed. Returns an
|
|
|
|
|
empty string for empty lists and ``str(content)`` for unexpected scalar
|
|
|
|
|
types.
|
2026-04-20 05:17:44 -07:00
|
|
|
"""
|
|
|
|
|
if content is None:
|
|
|
|
|
return ""
|
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
return content
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
text_bits: List[str] = []
|
|
|
|
|
image_count = 0
|
|
|
|
|
for part in content:
|
|
|
|
|
if isinstance(part, str):
|
|
|
|
|
if part:
|
|
|
|
|
text_bits.append(part)
|
|
|
|
|
continue
|
|
|
|
|
if not isinstance(part, dict):
|
|
|
|
|
continue
|
|
|
|
|
ptype = str(part.get("type") or "").strip().lower()
|
|
|
|
|
if ptype in {"text", "input_text", "output_text"}:
|
|
|
|
|
text = part.get("text")
|
|
|
|
|
if isinstance(text, str) and text:
|
|
|
|
|
text_bits.append(text)
|
|
|
|
|
elif ptype in {"image_url", "input_image"}:
|
|
|
|
|
image_count += 1
|
2026-06-12 12:49:18 +05:30
|
|
|
summary = sep.join(text_bits).strip()
|
2026-04-20 05:17:44 -07:00
|
|
|
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:
|
|
|
|
|
"""Generate a deterministic call_id from tool call content.
|
|
|
|
|
|
|
|
|
|
Used as a fallback when the API doesn't provide a call_id.
|
|
|
|
|
Deterministic IDs prevent cache invalidation — random UUIDs would
|
|
|
|
|
make every API call's prefix unique, breaking OpenAI's prompt cache.
|
|
|
|
|
"""
|
|
|
|
|
seed = f"{fn_name}:{arguments}:{index}"
|
|
|
|
|
digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12]
|
|
|
|
|
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):
|
|
|
|
|
return None, None
|
|
|
|
|
value = raw_id.strip()
|
|
|
|
|
if not value:
|
|
|
|
|
return None, None
|
|
|
|
|
if "|" in value:
|
|
|
|
|
call_id, response_item_id = value.split("|", 1)
|
|
|
|
|
call_id = call_id.strip() or None
|
|
|
|
|
response_item_id = response_item_id.strip() or None
|
|
|
|
|
return call_id, response_item_id
|
|
|
|
|
if value.startswith("fc_"):
|
|
|
|
|
return None, value
|
|
|
|
|
return value, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
candidate = response_item_id.strip()
|
|
|
|
|
if candidate.startswith("fc_"):
|
|
|
|
|
return candidate
|
|
|
|
|
|
|
|
|
|
source = (call_id or "").strip()
|
|
|
|
|
if source.startswith("fc_"):
|
|
|
|
|
return source
|
|
|
|
|
if source.startswith("call_") and len(source) > len("call_"):
|
|
|
|
|
return f"fc_{source[len('call_'):]}"
|
|
|
|
|
|
|
|
|
|
sanitized = re.sub(r"[^A-Za-z0-9_-]", "", source)
|
|
|
|
|
if sanitized.startswith("fc_"):
|
|
|
|
|
return sanitized
|
|
|
|
|
if sanitized.startswith("call_") and len(sanitized) > len("call_"):
|
|
|
|
|
return f"fc_{sanitized[len('call_'):]}"
|
|
|
|
|
if sanitized:
|
|
|
|
|
return f"fc_{sanitized[:48]}"
|
|
|
|
|
|
|
|
|
|
seed = source or str(response_item_id or "") or uuid.uuid4().hex
|
|
|
|
|
digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:24]
|
|
|
|
|
return f"fc_{digest}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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 isinstance(name, str) or not name.strip():
|
|
|
|
|
continue
|
|
|
|
|
converted.append({
|
|
|
|
|
"type": "function",
|
|
|
|
|
"name": name,
|
|
|
|
|
"description": fn.get("description", ""),
|
|
|
|
|
"strict": False,
|
|
|
|
|
"parameters": fn.get("parameters", {"type": "object", "properties": {}}),
|
|
|
|
|
})
|
|
|
|
|
return converted or None
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 11:06:01 -04:00
|
|
|
# Provider-executed built-in tool *declaration* types accepted on the
|
|
|
|
|
# Responses ``tools`` array. These are declared by ``type`` alone (no
|
|
|
|
|
# client-side name/parameters schema) and run server-side — the provider
|
|
|
|
|
# owns the implementation and reports progress via the matching ``*_call``
|
|
|
|
|
# output items. Hermes injects xAI's native ``web_search`` for the xAI
|
|
|
|
|
# transport (see agent/transports/codex.py); the rest are listed so the
|
|
|
|
|
# preflight validator passes them through rather than rejecting them as
|
|
|
|
|
# "unsupported type". Mirrors the ``*_call`` item-type set used in
|
|
|
|
|
# _normalize_codex_response.
|
|
|
|
|
_RESPONSES_BUILTIN_TOOL_TYPES = {
|
|
|
|
|
"web_search",
|
|
|
|
|
"web_search_preview",
|
|
|
|
|
"file_search",
|
|
|
|
|
"code_interpreter",
|
|
|
|
|
"image_generation",
|
|
|
|
|
"computer_use_preview",
|
|
|
|
|
"local_shell",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Message format conversion
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-25 23:14:12 +03:00
|
|
|
_RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str:
|
|
|
|
|
"""Normalize a Responses assistant message status for replay.
|
|
|
|
|
|
|
|
|
|
The API accepts completed/incomplete/in_progress on replayed assistant
|
|
|
|
|
output messages. Preserve those exactly (modulo case/hyphen spelling) so
|
|
|
|
|
incomplete Codex continuation turns don't get falsely marked completed.
|
|
|
|
|
"""
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
status = value.strip().lower().replace("-", "_").replace(" ", "_")
|
|
|
|
|
if status in _RESPONSE_MESSAGE_STATUSES:
|
|
|
|
|
return status
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 16:35:12 -07:00
|
|
|
def _chat_messages_to_responses_input(
|
|
|
|
|
messages: List[Dict[str, Any]],
|
|
|
|
|
*,
|
|
|
|
|
is_xai_responses: bool = False,
|
fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144) (#33035)
* fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144)
When an OpenAI-compatible Responses API surface accepts an initial
request but later rejects the replayed `codex_reasoning_items`
encrypted blob with HTTP 400 `invalid_encrypted_content`, the
session previously got stuck retrying the same poisoned payload.
Recovery: classify the error as a dedicated FailoverReason, and on the
first hit disable encrypted reasoning replay for the rest of the
session, strip cached items from message history, and retry once.
Changes:
* error_classifier: add FailoverReason.invalid_encrypted_content
branch in _classify_400 (before context_overflow so the messages
that mention 'encrypted content … could not be verified' don't trip
context heuristics), in _classify_by_error_code, and extend
_extract_error_code to peek inside wrapped JSON in error.message and
ignore the bare '400' as a code.
* agent_init: initialize `_codex_reasoning_replay_enabled = True` on
every agent.
* run_agent: add AIAgent._disable_codex_reasoning_replay() helper
that flips the flag and pops cached items.
* codex_responses_adapter: thread a `replay_encrypted_reasoning`
kwarg through _chat_messages_to_responses_input so that when the
flag is False we don't replay codex_reasoning_items.
* transports/codex.py: read `replay_encrypted_reasoning` from params,
thread it into the adapter, and gate the
`include=['reasoning.encrypted_content']` request hint on it.
* chat_completion_helpers: pass the agent's replay flag through to
the transport.
* conversation_loop: in the retry loop, add an
invalid_encrypted_content recovery branch that fires once per
session, only when api_mode == codex_responses, only when replay is
still enabled, and only when at least one assistant message in
history actually carries cached reasoning items (otherwise the 400
has nothing to do with our cache and the normal retry path handles
it).
Tests:
* test_error_classifier: new wrapped-JSON _extract_error_code case;
new TestClassifyApiError cases proving the 400 is retryable with
no fallback, that the broad message match doesn't catch a generic
'parsed' message, and that the error code match is
case-insensitive.
* test_run_agent_codex_responses: end-to-end test of the recovery
branch firing once and disabling replay, plus a sibling test that
proves the branch does *not* fire (and the flag stays True) when
history has no cached reasoning items.
Salvages PR #10144 onto the post-refactor module layout
(error_classifier / codex_responses_adapter / transports/codex /
conversation_loop / agent_init) since the original diff was written
against the pre-refactor monolithic run_agent.py.
* chore(release): map victorGPT in AUTHOR_MAP for #10144 salvage
---------
Co-authored-by: victorGPT <wuxuebin1993@gmail.com>
2026-05-26 22:01:17 -07:00
|
|
|
replay_encrypted_reasoning: bool = True,
|
2026-05-27 02:30:43 -07:00
|
|
|
current_issuer_kind: Optional[str] = None,
|
2026-05-15 16:35:12 -07:00
|
|
|
) -> List[Dict[str, Any]]:
|
|
|
|
|
"""Convert internal chat-style messages to Responses input items.
|
|
|
|
|
|
2026-05-20 22:14:18 -07:00
|
|
|
``is_xai_responses`` is kept for transport signature compatibility but
|
|
|
|
|
no longer suppresses encrypted reasoning replay. Earlier (PR #26644,
|
|
|
|
|
May 2026) we believed xAI's OAuth/SuperGrok ``/v1/responses`` surface
|
|
|
|
|
rejected replayed ``encrypted_content`` reasoning items minted by
|
|
|
|
|
prior turns, and we stripped them. That decision was wrong — xAI
|
|
|
|
|
explicitly relies on Hermes threading encrypted reasoning back across
|
|
|
|
|
turns for cross-turn coherence (the whole point of their partnership
|
|
|
|
|
integration). We now replay encrypted reasoning on every Responses
|
|
|
|
|
transport (xAI, native Codex, custom relays) and let xAI tell us
|
|
|
|
|
explicitly if a specific surface ever rejects a payload.
|
fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144) (#33035)
* fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144)
When an OpenAI-compatible Responses API surface accepts an initial
request but later rejects the replayed `codex_reasoning_items`
encrypted blob with HTTP 400 `invalid_encrypted_content`, the
session previously got stuck retrying the same poisoned payload.
Recovery: classify the error as a dedicated FailoverReason, and on the
first hit disable encrypted reasoning replay for the rest of the
session, strip cached items from message history, and retry once.
Changes:
* error_classifier: add FailoverReason.invalid_encrypted_content
branch in _classify_400 (before context_overflow so the messages
that mention 'encrypted content … could not be verified' don't trip
context heuristics), in _classify_by_error_code, and extend
_extract_error_code to peek inside wrapped JSON in error.message and
ignore the bare '400' as a code.
* agent_init: initialize `_codex_reasoning_replay_enabled = True` on
every agent.
* run_agent: add AIAgent._disable_codex_reasoning_replay() helper
that flips the flag and pops cached items.
* codex_responses_adapter: thread a `replay_encrypted_reasoning`
kwarg through _chat_messages_to_responses_input so that when the
flag is False we don't replay codex_reasoning_items.
* transports/codex.py: read `replay_encrypted_reasoning` from params,
thread it into the adapter, and gate the
`include=['reasoning.encrypted_content']` request hint on it.
* chat_completion_helpers: pass the agent's replay flag through to
the transport.
* conversation_loop: in the retry loop, add an
invalid_encrypted_content recovery branch that fires once per
session, only when api_mode == codex_responses, only when replay is
still enabled, and only when at least one assistant message in
history actually carries cached reasoning items (otherwise the 400
has nothing to do with our cache and the normal retry path handles
it).
Tests:
* test_error_classifier: new wrapped-JSON _extract_error_code case;
new TestClassifyApiError cases proving the 400 is retryable with
no fallback, that the broad message match doesn't catch a generic
'parsed' message, and that the error code match is
case-insensitive.
* test_run_agent_codex_responses: end-to-end test of the recovery
branch firing once and disabling replay, plus a sibling test that
proves the branch does *not* fire (and the flag stays True) when
history has no cached reasoning items.
Salvages PR #10144 onto the post-refactor module layout
(error_classifier / codex_responses_adapter / transports/codex /
conversation_loop / agent_init) since the original diff was written
against the pre-refactor monolithic run_agent.py.
* chore(release): map victorGPT in AUTHOR_MAP for #10144 salvage
---------
Co-authored-by: victorGPT <wuxuebin1993@gmail.com>
2026-05-26 22:01:17 -07:00
|
|
|
|
|
|
|
|
``replay_encrypted_reasoning`` is the per-session kill switch. Some
|
|
|
|
|
OpenAI-compatible relays accept the request but later reject the
|
|
|
|
|
replayed encrypted blob with HTTP 400 ``invalid_encrypted_content``;
|
|
|
|
|
when that happens the retry loop calls
|
|
|
|
|
``AIAgent._disable_codex_reasoning_replay`` which both strips cached
|
|
|
|
|
items from the conversation history and threads ``replay_enabled=False``
|
|
|
|
|
through this converter so subsequent turns send no reasoning items.
|
2026-05-27 02:30:43 -07:00
|
|
|
|
|
|
|
|
``current_issuer_kind`` enables a per-item cross-issuer guard. The
|
|
|
|
|
Responses API's ``encrypted_content`` blob is decryptable only by the
|
|
|
|
|
endpoint that minted it — replaying a Codex-issued blob against xAI
|
|
|
|
|
(or vice versa) always yields HTTP 400 ``invalid_encrypted_content``
|
|
|
|
|
and breaks every subsequent turn in the same session. When this
|
|
|
|
|
argument is provided and a reasoning item carries an ``_issuer_kind``
|
|
|
|
|
stamp from a different endpoint, the item is dropped from the replayed
|
|
|
|
|
input. Legacy items without a stamp are still replayed
|
|
|
|
|
(backwards-compatible). The two guards compose:
|
|
|
|
|
``replay_encrypted_reasoning=False`` is the session-wide kill switch
|
|
|
|
|
(drops ALL replay); ``current_issuer_kind`` is the per-item filter
|
|
|
|
|
that runs only when replay is still enabled.
|
2026-05-15 16:35:12 -07:00
|
|
|
"""
|
2026-04-20 05:17:44 -07:00
|
|
|
items: List[Dict[str, Any]] = []
|
|
|
|
|
seen_item_ids: set = set()
|
|
|
|
|
|
|
|
|
|
for msg in messages:
|
|
|
|
|
if not isinstance(msg, dict):
|
|
|
|
|
continue
|
|
|
|
|
role = msg.get("role")
|
|
|
|
|
if role == "system":
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if role in {"user", "assistant"}:
|
|
|
|
|
content = msg.get("content", "")
|
|
|
|
|
if isinstance(content, list):
|
2026-04-25 10:13:29 -07:00
|
|
|
content_parts = _chat_content_to_responses_parts(content, role=role)
|
|
|
|
|
text_type = "output_text" if role == "assistant" else "input_text"
|
2026-04-20 05:17:44 -07:00
|
|
|
content_text = "".join(
|
2026-04-25 10:13:29 -07:00
|
|
|
p.get("text", "") for p in content_parts if p.get("type") == text_type
|
2026-04-20 05:17:44 -07:00
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
content_parts = []
|
|
|
|
|
content_text = str(content) if content is not None else ""
|
|
|
|
|
|
|
|
|
|
if role == "assistant":
|
|
|
|
|
# Replay encrypted reasoning items from previous turns
|
|
|
|
|
# so the API can maintain coherent reasoning chains.
|
2026-05-20 22:14:18 -07:00
|
|
|
# This applies to every Responses transport including
|
|
|
|
|
# xAI — see _chat_messages_to_responses_input docstring
|
|
|
|
|
# for the May 2026 reversal of the earlier xAI gate.
|
fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144) (#33035)
* fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144)
When an OpenAI-compatible Responses API surface accepts an initial
request but later rejects the replayed `codex_reasoning_items`
encrypted blob with HTTP 400 `invalid_encrypted_content`, the
session previously got stuck retrying the same poisoned payload.
Recovery: classify the error as a dedicated FailoverReason, and on the
first hit disable encrypted reasoning replay for the rest of the
session, strip cached items from message history, and retry once.
Changes:
* error_classifier: add FailoverReason.invalid_encrypted_content
branch in _classify_400 (before context_overflow so the messages
that mention 'encrypted content … could not be verified' don't trip
context heuristics), in _classify_by_error_code, and extend
_extract_error_code to peek inside wrapped JSON in error.message and
ignore the bare '400' as a code.
* agent_init: initialize `_codex_reasoning_replay_enabled = True` on
every agent.
* run_agent: add AIAgent._disable_codex_reasoning_replay() helper
that flips the flag and pops cached items.
* codex_responses_adapter: thread a `replay_encrypted_reasoning`
kwarg through _chat_messages_to_responses_input so that when the
flag is False we don't replay codex_reasoning_items.
* transports/codex.py: read `replay_encrypted_reasoning` from params,
thread it into the adapter, and gate the
`include=['reasoning.encrypted_content']` request hint on it.
* chat_completion_helpers: pass the agent's replay flag through to
the transport.
* conversation_loop: in the retry loop, add an
invalid_encrypted_content recovery branch that fires once per
session, only when api_mode == codex_responses, only when replay is
still enabled, and only when at least one assistant message in
history actually carries cached reasoning items (otherwise the 400
has nothing to do with our cache and the normal retry path handles
it).
Tests:
* test_error_classifier: new wrapped-JSON _extract_error_code case;
new TestClassifyApiError cases proving the 400 is retryable with
no fallback, that the broad message match doesn't catch a generic
'parsed' message, and that the error code match is
case-insensitive.
* test_run_agent_codex_responses: end-to-end test of the recovery
branch firing once and disabling replay, plus a sibling test that
proves the branch does *not* fire (and the flag stays True) when
history has no cached reasoning items.
Salvages PR #10144 onto the post-refactor module layout
(error_classifier / codex_responses_adapter / transports/codex /
conversation_loop / agent_init) since the original diff was written
against the pre-refactor monolithic run_agent.py.
* chore(release): map victorGPT in AUTHOR_MAP for #10144 salvage
---------
Co-authored-by: victorGPT <wuxuebin1993@gmail.com>
2026-05-26 22:01:17 -07:00
|
|
|
codex_reasoning = (
|
|
|
|
|
msg.get("codex_reasoning_items")
|
|
|
|
|
if replay_encrypted_reasoning
|
|
|
|
|
else None
|
|
|
|
|
)
|
2026-04-20 05:17:44 -07:00
|
|
|
has_codex_reasoning = False
|
2026-05-20 22:14:18 -07:00
|
|
|
if isinstance(codex_reasoning, list):
|
2026-04-20 05:17:44 -07:00
|
|
|
for ri in codex_reasoning:
|
|
|
|
|
if isinstance(ri, dict) and ri.get("encrypted_content"):
|
|
|
|
|
item_id = ri.get("id")
|
|
|
|
|
if item_id and item_id in seen_item_ids:
|
|
|
|
|
continue
|
2026-05-27 02:30:43 -07:00
|
|
|
# Cross-issuer guard: drop reasoning blocks that
|
|
|
|
|
# were minted by a different Responses endpoint.
|
|
|
|
|
# The current endpoint cannot decrypt foreign
|
|
|
|
|
# encrypted_content and would reject the whole
|
|
|
|
|
# request with HTTP 400 invalid_encrypted_content.
|
|
|
|
|
# Unstamped (legacy) items pass through.
|
|
|
|
|
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
|
|
|
|
|
):
|
|
|
|
|
global _CROSS_ISSUER_WARN_EMITTED
|
|
|
|
|
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
|
2026-04-20 05:17:44 -07:00
|
|
|
# Strip the "id" field — with store=False the
|
|
|
|
|
# Responses API cannot look up items by ID and
|
|
|
|
|
# returns 404. The encrypted_content blob is
|
|
|
|
|
# self-contained for reasoning chain continuity.
|
2026-05-27 02:30:43 -07:00
|
|
|
# Also strip the internal "_issuer_kind" stamp;
|
|
|
|
|
# it is a Hermes-side metadata key and not part
|
|
|
|
|
# of the Responses API schema.
|
|
|
|
|
replay_item = {
|
|
|
|
|
k: v for k, v in ri.items()
|
|
|
|
|
if k not in ("id", "_issuer_kind")
|
|
|
|
|
}
|
2026-04-20 05:17:44 -07:00
|
|
|
items.append(replay_item)
|
|
|
|
|
if item_id:
|
|
|
|
|
seen_item_ids.add(item_id)
|
|
|
|
|
has_codex_reasoning = True
|
|
|
|
|
|
2026-04-25 23:14:12 +03:00
|
|
|
# Replay exact assistant message items (with id/phase) from
|
|
|
|
|
# previous turns so the API can maintain prefix-cache hits.
|
|
|
|
|
# OpenAI docs: "preserve and resend phase on all assistant
|
|
|
|
|
# messages — dropping it can degrade performance."
|
|
|
|
|
codex_message_items = msg.get("codex_message_items")
|
|
|
|
|
replayed_message_items = 0
|
|
|
|
|
if isinstance(codex_message_items, list):
|
|
|
|
|
for raw_item in codex_message_items:
|
|
|
|
|
if not isinstance(raw_item, dict):
|
|
|
|
|
continue
|
|
|
|
|
if raw_item.get("type") != "message" or raw_item.get("role") != "assistant":
|
|
|
|
|
continue
|
|
|
|
|
raw_content_parts = raw_item.get("content")
|
|
|
|
|
if not isinstance(raw_content_parts, list):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
normalized_content_parts = []
|
|
|
|
|
for part in raw_content_parts:
|
|
|
|
|
if not isinstance(part, dict):
|
|
|
|
|
continue
|
|
|
|
|
part_type = str(part.get("type") or "").strip()
|
|
|
|
|
if part_type not in {"output_text", "text"}:
|
|
|
|
|
continue
|
|
|
|
|
text = part.get("text", "")
|
|
|
|
|
if text is None:
|
|
|
|
|
text = ""
|
|
|
|
|
if not isinstance(text, str):
|
|
|
|
|
text = str(text)
|
|
|
|
|
normalized_content_parts.append({"type": "output_text", "text": text})
|
|
|
|
|
|
|
|
|
|
if not normalized_content_parts:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
replay_item = {
|
|
|
|
|
"type": "message",
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"status": _normalize_responses_message_status(raw_item.get("status")),
|
|
|
|
|
"content": normalized_content_parts,
|
|
|
|
|
}
|
|
|
|
|
item_id = raw_item.get("id")
|
|
|
|
|
if isinstance(item_id, str) and item_id.strip():
|
|
|
|
|
replay_item["id"] = item_id.strip()
|
|
|
|
|
phase = raw_item.get("phase")
|
|
|
|
|
if isinstance(phase, str) and phase.strip():
|
|
|
|
|
replay_item["phase"] = phase.strip()
|
|
|
|
|
items.append(replay_item)
|
|
|
|
|
replayed_message_items += 1
|
|
|
|
|
|
|
|
|
|
if replayed_message_items > 0:
|
|
|
|
|
pass
|
|
|
|
|
elif content_parts:
|
2026-04-20 05:17:44 -07:00
|
|
|
items.append({"role": "assistant", "content": content_parts})
|
|
|
|
|
elif content_text.strip():
|
|
|
|
|
items.append({"role": "assistant", "content": content_text})
|
|
|
|
|
elif has_codex_reasoning:
|
|
|
|
|
# The Responses API requires a following item after each
|
|
|
|
|
# reasoning item (otherwise: missing_following_item error).
|
|
|
|
|
# When the assistant produced only reasoning with no visible
|
|
|
|
|
# content, emit an empty assistant message as the required
|
|
|
|
|
# following item.
|
|
|
|
|
items.append({"role": "assistant", "content": ""})
|
|
|
|
|
|
|
|
|
|
tool_calls = msg.get("tool_calls")
|
|
|
|
|
if isinstance(tool_calls, list):
|
|
|
|
|
for tc in tool_calls:
|
|
|
|
|
if not isinstance(tc, dict):
|
|
|
|
|
continue
|
|
|
|
|
fn = tc.get("function", {})
|
|
|
|
|
fn_name = fn.get("name")
|
|
|
|
|
if not isinstance(fn_name, str) or not fn_name.strip():
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
embedded_call_id, embedded_response_item_id = _split_responses_tool_id(
|
|
|
|
|
tc.get("id")
|
|
|
|
|
)
|
|
|
|
|
call_id = tc.get("call_id")
|
|
|
|
|
if not isinstance(call_id, str) or not call_id.strip():
|
|
|
|
|
call_id = embedded_call_id
|
|
|
|
|
if not isinstance(call_id, str) or not call_id.strip():
|
|
|
|
|
if (
|
|
|
|
|
isinstance(embedded_response_item_id, str)
|
|
|
|
|
and embedded_response_item_id.startswith("fc_")
|
|
|
|
|
and len(embedded_response_item_id) > len("fc_")
|
|
|
|
|
):
|
|
|
|
|
call_id = f"call_{embedded_response_item_id[len('fc_'):]}"
|
|
|
|
|
else:
|
|
|
|
|
_raw_args = str(fn.get("arguments", "{}"))
|
|
|
|
|
call_id = _deterministic_call_id(fn_name, _raw_args, len(items))
|
|
|
|
|
call_id = call_id.strip()
|
|
|
|
|
|
|
|
|
|
arguments = fn.get("arguments", "{}")
|
|
|
|
|
if isinstance(arguments, dict):
|
|
|
|
|
arguments = json.dumps(arguments, ensure_ascii=False)
|
|
|
|
|
elif not isinstance(arguments, str):
|
|
|
|
|
arguments = str(arguments)
|
|
|
|
|
arguments = arguments.strip() or "{}"
|
|
|
|
|
|
|
|
|
|
items.append({
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
"call_id": call_id,
|
|
|
|
|
"name": fn_name,
|
|
|
|
|
"arguments": arguments,
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Non-assistant (user) role: emit multimodal parts when present,
|
|
|
|
|
# otherwise fall back to the text payload.
|
|
|
|
|
if content_parts:
|
|
|
|
|
items.append({"role": role, "content": content_parts})
|
|
|
|
|
else:
|
|
|
|
|
items.append({"role": role, "content": content_text})
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if role == "tool":
|
|
|
|
|
raw_tool_call_id = msg.get("tool_call_id")
|
|
|
|
|
call_id, _ = _split_responses_tool_id(raw_tool_call_id)
|
|
|
|
|
if not isinstance(call_id, str) or not call_id.strip():
|
|
|
|
|
if isinstance(raw_tool_call_id, str) and raw_tool_call_id.strip():
|
|
|
|
|
call_id = raw_tool_call_id.strip()
|
|
|
|
|
if not isinstance(call_id, str) or not call_id.strip():
|
|
|
|
|
continue
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
|
|
|
|
# Multimodal tool result: convert OpenAI-style content list into
|
|
|
|
|
# Responses ``function_call_output.output`` array. The Responses
|
|
|
|
|
# API accepts ``output`` as either a string or an array of
|
|
|
|
|
# ``input_text``/``input_image`` items. See
|
|
|
|
|
# https://developers.openai.com/api/reference/python/resources/responses/.
|
|
|
|
|
tool_content = msg.get("content")
|
|
|
|
|
output_value: Any
|
|
|
|
|
if isinstance(tool_content, list):
|
|
|
|
|
converted = _chat_content_to_responses_parts(
|
|
|
|
|
tool_content, role="user",
|
|
|
|
|
)
|
|
|
|
|
if converted:
|
|
|
|
|
output_value = converted
|
|
|
|
|
else:
|
|
|
|
|
output_value = ""
|
|
|
|
|
else:
|
|
|
|
|
output_value = str(tool_content or "")
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
items.append({
|
|
|
|
|
"type": "function_call_output",
|
|
|
|
|
"call_id": call_id,
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
"output": output_value,
|
2026-04-20 05:17:44 -07:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Input preflight / validation
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
|
|
|
|
|
if not isinstance(raw_items, list):
|
|
|
|
|
raise ValueError("Codex Responses input must be a list of input items.")
|
|
|
|
|
|
|
|
|
|
normalized: List[Dict[str, Any]] = []
|
|
|
|
|
seen_ids: set = set()
|
|
|
|
|
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")
|
|
|
|
|
if item_type == "function_call":
|
|
|
|
|
call_id = item.get("call_id")
|
|
|
|
|
name = item.get("name")
|
|
|
|
|
if not isinstance(call_id, str) or not call_id.strip():
|
|
|
|
|
raise ValueError(f"Codex Responses input[{idx}] function_call is missing call_id.")
|
|
|
|
|
if not isinstance(name, str) or not name.strip():
|
|
|
|
|
raise ValueError(f"Codex Responses input[{idx}] function_call is missing name.")
|
|
|
|
|
|
|
|
|
|
arguments = item.get("arguments", "{}")
|
|
|
|
|
if isinstance(arguments, dict):
|
|
|
|
|
arguments = json.dumps(arguments, ensure_ascii=False)
|
|
|
|
|
elif not isinstance(arguments, str):
|
|
|
|
|
arguments = str(arguments)
|
|
|
|
|
arguments = arguments.strip() or "{}"
|
|
|
|
|
|
|
|
|
|
normalized.append(
|
|
|
|
|
{
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
"call_id": call_id.strip(),
|
|
|
|
|
"name": name.strip(),
|
|
|
|
|
"arguments": arguments,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if item_type == "function_call_output":
|
|
|
|
|
call_id = item.get("call_id")
|
|
|
|
|
if not isinstance(call_id, str) or not call_id.strip():
|
|
|
|
|
raise ValueError(f"Codex Responses input[{idx}] function_call_output is missing call_id.")
|
|
|
|
|
output = item.get("output", "")
|
|
|
|
|
if output is None:
|
|
|
|
|
output = ""
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
# Output may be a string OR an array of structured content
|
|
|
|
|
# items (input_text / input_image) for multimodal tool results.
|
|
|
|
|
# Both shapes are accepted by the Responses API. We preserve
|
|
|
|
|
# the array form when present.
|
|
|
|
|
if isinstance(output, list):
|
|
|
|
|
# Validate each item is a recognised content shape; drop
|
|
|
|
|
# anything else to avoid 4xx from the API.
|
|
|
|
|
cleaned: List[Dict[str, Any]] = []
|
|
|
|
|
for part in output:
|
|
|
|
|
if not isinstance(part, dict):
|
|
|
|
|
continue
|
|
|
|
|
ptype = part.get("type")
|
|
|
|
|
if ptype == "input_text":
|
|
|
|
|
text = part.get("text")
|
|
|
|
|
if isinstance(text, str) and text:
|
|
|
|
|
cleaned.append({"type": "input_text", "text": text})
|
|
|
|
|
elif ptype == "input_image":
|
|
|
|
|
url = part.get("image_url")
|
|
|
|
|
if isinstance(url, str) and url:
|
|
|
|
|
entry: Dict[str, Any] = {"type": "input_image", "image_url": url}
|
|
|
|
|
detail = part.get("detail")
|
|
|
|
|
if isinstance(detail, str) and detail.strip():
|
|
|
|
|
entry["detail"] = detail.strip()
|
|
|
|
|
cleaned.append(entry)
|
|
|
|
|
normalized.append(
|
|
|
|
|
{
|
|
|
|
|
"type": "function_call_output",
|
|
|
|
|
"call_id": call_id.strip(),
|
|
|
|
|
"output": cleaned if cleaned else "",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
continue
|
2026-04-20 05:17:44 -07:00
|
|
|
if not isinstance(output, str):
|
|
|
|
|
output = str(output)
|
|
|
|
|
|
|
|
|
|
normalized.append(
|
|
|
|
|
{
|
|
|
|
|
"type": "function_call_output",
|
|
|
|
|
"call_id": call_id.strip(),
|
|
|
|
|
"output": output,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if item_type == "reasoning":
|
|
|
|
|
encrypted = item.get("encrypted_content")
|
|
|
|
|
if isinstance(encrypted, str) and encrypted:
|
|
|
|
|
item_id = item.get("id")
|
|
|
|
|
if isinstance(item_id, str) and item_id:
|
|
|
|
|
if item_id in seen_ids:
|
|
|
|
|
continue
|
|
|
|
|
seen_ids.add(item_id)
|
|
|
|
|
reasoning_item = {"type": "reasoning", "encrypted_content": encrypted}
|
|
|
|
|
# Do NOT include the "id" in the outgoing item — with
|
|
|
|
|
# store=False (our default) the API tries to resolve the
|
|
|
|
|
# id server-side and returns 404. The id is still used
|
|
|
|
|
# above for local deduplication via seen_ids.
|
|
|
|
|
summary = item.get("summary")
|
|
|
|
|
if isinstance(summary, list):
|
|
|
|
|
reasoning_item["summary"] = summary
|
|
|
|
|
else:
|
|
|
|
|
reasoning_item["summary"] = []
|
|
|
|
|
normalized.append(reasoning_item)
|
|
|
|
|
continue
|
|
|
|
|
|
2026-04-25 23:14:12 +03:00
|
|
|
if item_type == "message":
|
|
|
|
|
role = item.get("role")
|
|
|
|
|
if 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 = part.get("text", "")
|
|
|
|
|
if text is None:
|
|
|
|
|
text = ""
|
|
|
|
|
if not isinstance(text, str):
|
|
|
|
|
text = str(text)
|
|
|
|
|
normalized_content.append({"type": "output_text", "text": text})
|
|
|
|
|
if not normalized_content:
|
|
|
|
|
raise ValueError(f"Codex Responses input[{idx}] message item must contain at least one text part.")
|
|
|
|
|
normalized_item: Dict[str, Any] = {
|
|
|
|
|
"type": "message",
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"status": _normalize_responses_message_status(item.get("status")),
|
|
|
|
|
"content": normalized_content,
|
|
|
|
|
}
|
|
|
|
|
item_id = item.get("id")
|
|
|
|
|
if isinstance(item_id, str) and item_id.strip():
|
|
|
|
|
normalized_item["id"] = item_id.strip()
|
|
|
|
|
phase = item.get("phase")
|
|
|
|
|
if isinstance(phase, str) and phase.strip():
|
|
|
|
|
normalized_item["phase"] = phase.strip()
|
|
|
|
|
normalized.append(normalized_item)
|
|
|
|
|
continue
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
role = item.get("role")
|
|
|
|
|
if role in {"user", "assistant"}:
|
|
|
|
|
content = item.get("content", "")
|
|
|
|
|
if content is None:
|
|
|
|
|
content = ""
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
# Multimodal content from ``_chat_messages_to_responses_input``
|
2026-04-25 10:13:29 -07:00
|
|
|
# is already in Responses format (``input_text`` / ``output_text``
|
|
|
|
|
# / ``input_image``). Validate each part and pass through.
|
|
|
|
|
# Use the correct text type for the role — ``output_text`` for
|
|
|
|
|
# assistant messages, ``input_text`` for user messages.
|
|
|
|
|
text_type = "output_text" if role == "assistant" else "input_text"
|
2026-04-20 05:17:44 -07:00
|
|
|
validated: List[Dict[str, Any]] = []
|
|
|
|
|
for part_idx, part in enumerate(content):
|
|
|
|
|
if isinstance(part, str):
|
|
|
|
|
if part:
|
2026-04-25 10:13:29 -07:00
|
|
|
validated.append({"type": text_type, "text": part})
|
2026-04-20 05:17:44 -07:00
|
|
|
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 {"input_text", "text", "output_text"}:
|
|
|
|
|
text = part.get("text", "")
|
|
|
|
|
if not isinstance(text, str):
|
|
|
|
|
text = str(text or "")
|
2026-04-25 10:13:29 -07:00
|
|
|
validated.append({"type": text_type, "text": text})
|
2026-04-20 05:17:44 -07:00
|
|
|
elif ptype in {"input_image", "image_url"}:
|
|
|
|
|
image_ref = part.get("image_url", "")
|
|
|
|
|
detail = part.get("detail")
|
|
|
|
|
if isinstance(image_ref, dict):
|
|
|
|
|
url = image_ref.get("url", "")
|
|
|
|
|
detail = image_ref.get("detail", detail)
|
|
|
|
|
else:
|
|
|
|
|
url = image_ref
|
|
|
|
|
if not isinstance(url, str):
|
|
|
|
|
url = str(url or "")
|
|
|
|
|
image_part: Dict[str, Any] = {"type": "input_image", "image_url": url}
|
|
|
|
|
if isinstance(detail, str) and detail.strip():
|
|
|
|
|
image_part["detail"] = detail.strip()
|
|
|
|
|
validated.append(image_part)
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"Codex Responses input[{idx}].content[{part_idx}] has unsupported type {part.get('type')!r}."
|
|
|
|
|
)
|
|
|
|
|
normalized.append({"role": role, "content": validated})
|
|
|
|
|
continue
|
|
|
|
|
if not isinstance(content, str):
|
|
|
|
|
content = str(content)
|
|
|
|
|
|
|
|
|
|
normalized.append({"role": role, "content": content})
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"Codex Responses input[{idx}] has unsupported item shape (type={item_type!r}, role={role!r})."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _preflight_codex_api_kwargs(
|
|
|
|
|
api_kwargs: Any,
|
|
|
|
|
*,
|
|
|
|
|
allow_stream: bool = False,
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
if not isinstance(api_kwargs, dict):
|
|
|
|
|
raise ValueError("Codex Responses request must be a dict.")
|
|
|
|
|
|
|
|
|
|
required = {"model", "instructions", "input"}
|
|
|
|
|
missing = [key for key in required 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 isinstance(model, str) or not model.strip():
|
|
|
|
|
raise ValueError("Codex Responses request 'model' must be a non-empty string.")
|
|
|
|
|
model = model.strip()
|
|
|
|
|
|
|
|
|
|
instructions = api_kwargs.get("instructions")
|
|
|
|
|
if instructions is None:
|
|
|
|
|
instructions = ""
|
|
|
|
|
if not isinstance(instructions, str):
|
|
|
|
|
instructions = str(instructions)
|
|
|
|
|
instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY
|
|
|
|
|
|
|
|
|
|
normalized_input = _preflight_codex_input_items(api_kwargs.get("input"))
|
|
|
|
|
|
|
|
|
|
tools = api_kwargs.get("tools")
|
|
|
|
|
normalized_tools = None
|
|
|
|
|
if tools is not None:
|
|
|
|
|
if not isinstance(tools, list):
|
|
|
|
|
raise ValueError("Codex Responses request 'tools' must be a list when provided.")
|
|
|
|
|
normalized_tools = []
|
|
|
|
|
for idx, tool in enumerate(tools):
|
|
|
|
|
if not isinstance(tool, dict):
|
|
|
|
|
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
|
2026-06-11 11:06:01 -04:00
|
|
|
|
|
|
|
|
tool_type = tool.get("type")
|
|
|
|
|
|
|
|
|
|
# Provider-executed built-in tools (xAI native web_search, code
|
|
|
|
|
# interpreter, etc.) are declared by ``type`` alone and carry no
|
|
|
|
|
# ``name``/``parameters`` schema — the provider owns the
|
|
|
|
|
# implementation. Pass them through verbatim instead of forcing
|
|
|
|
|
# them through the function-tool validation below (which would
|
|
|
|
|
# otherwise reject them with "unsupported type"). See
|
|
|
|
|
# agent/transports/codex.py for where xAI's native web_search is
|
|
|
|
|
# injected.
|
|
|
|
|
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
|
|
|
|
|
normalized_tools.append(dict(tool))
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if tool_type != "function":
|
2026-04-20 05:17:44 -07:00
|
|
|
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
|
|
|
|
|
|
|
|
|
|
name = tool.get("name")
|
|
|
|
|
parameters = tool.get("parameters")
|
|
|
|
|
if not isinstance(name, str) or not name.strip():
|
|
|
|
|
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.")
|
|
|
|
|
|
|
|
|
|
description = tool.get("description", "")
|
|
|
|
|
if description is None:
|
|
|
|
|
description = ""
|
|
|
|
|
if not isinstance(description, str):
|
|
|
|
|
description = str(description)
|
|
|
|
|
|
|
|
|
|
strict = tool.get("strict", False)
|
|
|
|
|
if not isinstance(strict, bool):
|
|
|
|
|
strict = bool(strict)
|
|
|
|
|
|
|
|
|
|
normalized_tools.append(
|
|
|
|
|
{
|
|
|
|
|
"type": "function",
|
|
|
|
|
"name": name.strip(),
|
|
|
|
|
"description": description,
|
|
|
|
|
"strict": strict,
|
|
|
|
|
"parameters": parameters,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
store = api_kwargs.get("store", False)
|
|
|
|
|
if store is not False:
|
|
|
|
|
raise ValueError("Codex Responses contract requires 'store' to be false.")
|
|
|
|
|
|
|
|
|
|
allowed_keys = {
|
|
|
|
|
"model", "instructions", "input", "tools", "store",
|
|
|
|
|
"reasoning", "include", "max_output_tokens", "temperature",
|
|
|
|
|
"tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier",
|
fix(codex): size and propagate timeouts for Responses-API requests; lower stale defaults
Codex / Responses-API requests had three latent timeout bugs that combined
into the long silent hangs reported on #21444:
1. The non-stream stale-call detector estimated context tokens from
``api_kwargs["messages"]`` only. Codex / Responses-API payloads carry
their conversational load in ``input`` (with ``instructions`` and
``tools``), so every Codex turn logged ``context=~0 tokens`` and the
detector never applied its >50k / >100k tier bumps.
2. ``providers.<id>.request_timeout_seconds`` was silently dropped on the
main Codex path. The chat_completions path and the auxiliary Codex
adapter both forwarded it; the main path skipped it through three
places (``build_api_kwargs``, ``ResponsesApiTransport.build_kwargs``,
``_preflight_codex_api_kwargs``).
3. The streaming stale detector had the same payload-shape bug for
``codex_responses`` requests, which route through the non-streaming
detector (it's the path that emits the user-facing
"No response from provider for 300s (non-streaming, ...)" warning that
reporters keep pasting).
This commit:
- Adds ``estimate_request_context_tokens`` in ``chat_completion_helpers``,
used by both the non-stream and stream detectors. Handles ``messages``
(Chat Completions), ``input + instructions + tools`` (Responses API),
bare lists, and an unknown-dict fallback.
- Forwards ``timeout`` through ``ResponsesApiTransport.build_kwargs``
and ``_preflight_codex_api_kwargs`` (with guards against
zero/negative/inf/bool values), and wires
``_resolved_api_call_timeout()`` into the Codex branch of
``build_api_kwargs``.
- Lowers the implicit non-stream stale defaults so fallback providers
kick in faster when upstream stalls:
* base 300s -> 90s
* >50k 450s -> 150s
* >100k 600s -> 240s
These only apply when the user has *not* set
``providers.<id>.stale_timeout_seconds`` or
``HERMES_API_CALL_STALE_TIMEOUT``. Explicit config still wins.
- Adds regression tests for the estimator shapes, the new defaults, the
context-tier scaling, transport timeout pass-through, and preflight
timeout pass-through / rejection of invalid values.
Closes #21444
Supersedes #21652 #24126 #31855
Co-authored-by: Hoang V. Pham <26063003+hehehe0803@users.noreply.github.com>
2026-05-25 01:36:22 -07:00
|
|
|
"extra_headers", "extra_body", "timeout",
|
2026-04-20 05:17:44 -07:00
|
|
|
}
|
|
|
|
|
normalized: Dict[str, Any] = {
|
|
|
|
|
"model": model,
|
|
|
|
|
"instructions": instructions,
|
|
|
|
|
"input": normalized_input,
|
|
|
|
|
"store": False,
|
|
|
|
|
}
|
|
|
|
|
if normalized_tools is not None:
|
|
|
|
|
normalized["tools"] = normalized_tools
|
|
|
|
|
|
|
|
|
|
# Pass through reasoning config
|
|
|
|
|
reasoning = api_kwargs.get("reasoning")
|
|
|
|
|
if isinstance(reasoning, dict):
|
|
|
|
|
normalized["reasoning"] = reasoning
|
|
|
|
|
include = api_kwargs.get("include")
|
|
|
|
|
if isinstance(include, list):
|
|
|
|
|
normalized["include"] = include
|
|
|
|
|
service_tier = api_kwargs.get("service_tier")
|
|
|
|
|
if isinstance(service_tier, str) and service_tier.strip():
|
|
|
|
|
normalized["service_tier"] = service_tier.strip()
|
|
|
|
|
|
|
|
|
|
# Pass through max_output_tokens and temperature
|
|
|
|
|
max_output_tokens = api_kwargs.get("max_output_tokens")
|
|
|
|
|
if isinstance(max_output_tokens, (int, float)) and max_output_tokens > 0:
|
|
|
|
|
normalized["max_output_tokens"] = int(max_output_tokens)
|
fix(codex): size and propagate timeouts for Responses-API requests; lower stale defaults
Codex / Responses-API requests had three latent timeout bugs that combined
into the long silent hangs reported on #21444:
1. The non-stream stale-call detector estimated context tokens from
``api_kwargs["messages"]`` only. Codex / Responses-API payloads carry
their conversational load in ``input`` (with ``instructions`` and
``tools``), so every Codex turn logged ``context=~0 tokens`` and the
detector never applied its >50k / >100k tier bumps.
2. ``providers.<id>.request_timeout_seconds`` was silently dropped on the
main Codex path. The chat_completions path and the auxiliary Codex
adapter both forwarded it; the main path skipped it through three
places (``build_api_kwargs``, ``ResponsesApiTransport.build_kwargs``,
``_preflight_codex_api_kwargs``).
3. The streaming stale detector had the same payload-shape bug for
``codex_responses`` requests, which route through the non-streaming
detector (it's the path that emits the user-facing
"No response from provider for 300s (non-streaming, ...)" warning that
reporters keep pasting).
This commit:
- Adds ``estimate_request_context_tokens`` in ``chat_completion_helpers``,
used by both the non-stream and stream detectors. Handles ``messages``
(Chat Completions), ``input + instructions + tools`` (Responses API),
bare lists, and an unknown-dict fallback.
- Forwards ``timeout`` through ``ResponsesApiTransport.build_kwargs``
and ``_preflight_codex_api_kwargs`` (with guards against
zero/negative/inf/bool values), and wires
``_resolved_api_call_timeout()`` into the Codex branch of
``build_api_kwargs``.
- Lowers the implicit non-stream stale defaults so fallback providers
kick in faster when upstream stalls:
* base 300s -> 90s
* >50k 450s -> 150s
* >100k 600s -> 240s
These only apply when the user has *not* set
``providers.<id>.stale_timeout_seconds`` or
``HERMES_API_CALL_STALE_TIMEOUT``. Explicit config still wins.
- Adds regression tests for the estimator shapes, the new defaults, the
context-tier scaling, transport timeout pass-through, and preflight
timeout pass-through / rejection of invalid values.
Closes #21444
Supersedes #21652 #24126 #31855
Co-authored-by: Hoang V. Pham <26063003+hehehe0803@users.noreply.github.com>
2026-05-25 01:36:22 -07:00
|
|
|
timeout = api_kwargs.get("timeout")
|
|
|
|
|
if (
|
|
|
|
|
isinstance(timeout, (int, float))
|
|
|
|
|
and not isinstance(timeout, bool)
|
|
|
|
|
and 0 < float(timeout) < float("inf")
|
|
|
|
|
):
|
|
|
|
|
normalized["timeout"] = float(timeout)
|
2026-04-20 05:17:44 -07:00
|
|
|
temperature = api_kwargs.get("temperature")
|
|
|
|
|
if isinstance(temperature, (int, float)):
|
|
|
|
|
normalized["temperature"] = float(temperature)
|
|
|
|
|
|
|
|
|
|
# Pass through tool_choice, parallel_tool_calls, prompt_cache_key
|
|
|
|
|
for passthrough_key in ("tool_choice", "parallel_tool_calls", "prompt_cache_key"):
|
|
|
|
|
val = api_kwargs.get(passthrough_key)
|
|
|
|
|
if val is not None:
|
|
|
|
|
normalized[passthrough_key] = val
|
|
|
|
|
|
|
|
|
|
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.")
|
|
|
|
|
normalized_headers: Dict[str, str] = {}
|
|
|
|
|
for key, value in extra_headers.items():
|
|
|
|
|
if not isinstance(key, str) or not key.strip():
|
|
|
|
|
raise ValueError("Codex Responses request 'extra_headers' keys must be non-empty strings.")
|
|
|
|
|
if value is None:
|
|
|
|
|
continue
|
|
|
|
|
normalized_headers[key.strip()] = str(value)
|
|
|
|
|
if normalized_headers:
|
|
|
|
|
normalized["extra_headers"] = normalized_headers
|
|
|
|
|
|
feat(xai-oauth): add xAI Grok OAuth (SuperGrok Subscription) provider
Adds a new authentication provider that lets SuperGrok subscribers sign
in to Hermes with their xAI account via the standard OAuth 2.0 PKCE
loopback flow, instead of pasting a raw API key from console.x.ai.
Highlights
----------
* OAuth 2.0 PKCE loopback login against accounts.x.ai with discovery,
state/nonce, and a strict CORS-origin allowlist on the callback.
* Authorize URL carries `plan=generic` (required for non-allowlisted
loopback clients) and `referrer=hermes-agent` for best-effort
attribution in xAI's OAuth server logs.
* Token storage in `auth.json` with file-locked atomic writes; JWT
`exp`-based expiry detection with skew; refresh-token rotation
synced both ways between the singleton store and the credential
pool so multi-process / multi-profile setups don't tear each other's
refresh tokens.
* Reactive 401 retry: on a 401 from the xAI Responses API, the agent
refreshes the token, swaps it back into `self.api_key`, and retries
the call once. Guarded against silent account swaps when the active
key was sourced from a different (manual) pool entry.
* Auxiliary tasks (curator, vision, embeddings, etc.) route through a
dedicated xAI Responses-mode auxiliary client instead of falling back
to OpenRouter billing.
* Direct HTTP tools (`tools/xai_http.py`, transcription, TTS, image-gen
plugin) resolve credentials through a unified runtime → singleton →
env-var fallback chain so xai-oauth users get them for free.
* `hermes auth add xai-oauth` and `hermes auth remove xai-oauth N` are
wired through the standard auth-commands surface; remove cleans up
the singleton loopback_pkce entry so it doesn't silently reinstate.
* `hermes model` provider picker shows
"xAI Grok OAuth (SuperGrok Subscription)" and the model-flow falls
back to pool credentials when the singleton is missing.
Hardening
---------
* Discovery and refresh responses validate the returned
`token_endpoint` host against the same `*.x.ai` allowlist as the
authorization endpoint, blocking MITM persistence of a hostile
endpoint.
* Discovery / refresh / token-exchange `response.json()` calls are
wrapped to raise typed `AuthError` on malformed bodies (captive
portals, proxy error pages) instead of leaking JSONDecodeError
tracebacks.
* `prompt_cache_key` is routed through `extra_body` on the codex
transport (sending it as a top-level kwarg trips xAI's SDK with a
TypeError).
* Credential-pool sync-back preserves `active_provider` so refreshing
an OAuth entry doesn't silently flip the active provider out from
under the running agent.
Testing
-------
* New `tests/hermes_cli/test_auth_xai_oauth_provider.py` (~63 tests)
covers JWT expiry, OAuth URL params (plan + referrer), CORS origins,
redirect URI validation, singleton↔pool sync, concurrency races,
refresh error paths, runtime resolution, and malformed-JSON guards.
* Extended `test_credential_pool.py`, `test_codex_transport.py`, and
`test_run_agent_codex_responses.py` cover the pool sync-back,
`extra_body` routing, and 401 reactive refresh paths.
* 165 tests passing on this branch via `scripts/run_tests.sh`.
2026-05-15 16:10:38 +01:00
|
|
|
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.")
|
|
|
|
|
# Pass extra_body through verbatim — used by xAI Responses to
|
|
|
|
|
# carry `prompt_cache_key` as a body-level field (the documented
|
|
|
|
|
# cache-routing surface on /v1/responses). The openai SDK
|
|
|
|
|
# serializes extra_body into the JSON body without per-field
|
|
|
|
|
# type checks, so it survives Responses.stream() kwarg-signature
|
|
|
|
|
# changes that would otherwise raise TypeError before the wire.
|
|
|
|
|
if extra_body:
|
|
|
|
|
normalized["extra_body"] = dict(extra_body)
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
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.")
|
|
|
|
|
|
2026-05-19 11:23:40 +09:00
|
|
|
# Safety-net sanitization for xAI Responses (#28490): defense-in-depth
|
|
|
|
|
# for the same slash-enum strip that ``chat_completion_helpers`` and
|
|
|
|
|
# ``auxiliary_client`` apply at request-build time. If a future code
|
|
|
|
|
# path forgets to sanitize before calling us, this catches the bypass
|
|
|
|
|
# so xAI doesn't 400 with ``Invalid arguments passed to the model``
|
|
|
|
|
# (HuggingFace IDs like ``Qwen/Qwen3.5-0.8B`` from MCP tool schemas).
|
|
|
|
|
#
|
|
|
|
|
# Gated on the model name pattern because native Codex (OpenAI) DOES
|
|
|
|
|
# accept slash-containing enum values — stripping them there would
|
|
|
|
|
# silently degrade tool-schema constraints. xAI is the only
|
|
|
|
|
# Responses-API surface that rejects the shape.
|
|
|
|
|
model_name_for_provider_check = str(api_kwargs.get("model") or "").lower()
|
|
|
|
|
is_xai_model = model_name_for_provider_check.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
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
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 _extract_responses_message_text(item: Any) -> str:
|
|
|
|
|
"""Extract assistant text from a Responses message output item."""
|
|
|
|
|
content = getattr(item, "content", None)
|
|
|
|
|
if not isinstance(content, list):
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
chunks: List[str] = []
|
|
|
|
|
for part in content:
|
|
|
|
|
ptype = getattr(part, "type", None)
|
|
|
|
|
if ptype not in {"output_text", "text"}:
|
|
|
|
|
continue
|
|
|
|
|
text = getattr(part, "text", None)
|
|
|
|
|
if isinstance(text, str) and text:
|
|
|
|
|
chunks.append(text)
|
|
|
|
|
return "".join(chunks).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_responses_reasoning_text(item: Any) -> str:
|
|
|
|
|
"""Extract a compact reasoning text from a Responses reasoning item."""
|
|
|
|
|
summary = getattr(item, "summary", None)
|
|
|
|
|
if isinstance(summary, list):
|
|
|
|
|
chunks: List[str] = []
|
|
|
|
|
for part in summary:
|
|
|
|
|
text = getattr(part, "text", None)
|
|
|
|
|
if isinstance(text, str) and text:
|
|
|
|
|
chunks.append(text)
|
|
|
|
|
if chunks:
|
|
|
|
|
return "\n".join(chunks).strip()
|
|
|
|
|
text = getattr(item, "text", None)
|
|
|
|
|
if isinstance(text, str) and text:
|
|
|
|
|
return text.strip()
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
feat(prompt): universal task-completion guidance + local Python toolchain probe (#34340)
* fix(codex): surface error code in Responses 'failed' status errors
When a Codex Responses turn ends with status=failed, the response carries
the failure details under `response.error` as
`{code, message, param, ...}`. The previous extractor pulled only
`message`, so users seeing a rate-limit failure got a bare "Slow down"
string indistinguishable from a generic stream truncation; an
internal_error with empty message degraded to a dict dump
("{'code': 'internal_error', 'message': ''}").
Extract a `_format_responses_error()` helper that:
- prefixes `code` when both code and message are present
(e.g. 'rate_limit_exceeded: Slow down')
- falls back to the bare `code` when message is empty
- accepts both dict and attribute-style payloads (SDK and JSON-RPC paths)
- preserves the prior status-only fallback when no error payload exists
Apply the same helper at the sibling site in
`codex_app_server_session.run_turn()` so codex-CLI subprocess turn
failures get the same treatment.
Tests:
- 8 new unit tests for `_format_responses_error` covering both shapes,
empty/missing fields, non-string fields, and the status-only fallback.
- 2 regression tests on `_normalize_codex_response` for failed status
with and without a code, asserting the exact RuntimeError message.
- All 3603 tests in tests/agent/ pass.
Adapted from anomalyco/opencode#28757.
* feat(prompt): universal task-completion guidance + local Python toolchain probe
Two cross-model failure modes get a single-line answer in the cached
system prompt. Both gated by config (default on), both add zero overhead
when not needed, both verified via real AIAgent prompt builds.
## What changed
`TASK_COMPLETION_GUIDANCE` — short prompt block applied to ALL models.
Targets two failure modes observed on a real Sarasota real-estate build
task: (1) Opus stopped after writing an 85-byte stub and gave a prose
response with finish_reason=stop on call #3 of 90; (2) DeepSeek pushed
through a PEP-668 wall, then returned fabricated listings instead of
admitting the blocker. Both behaviors are model-family-agnostic, so the
guidance lives outside the existing tool_use_enforcement gate (~192
tokens, paid once per session via prefix cache).
`tools/env_probe.py` — local Python toolchain probe. Detects
python3/pip/uv/PEP-668 state and emits ONE short line in the system
prompt when something is non-default. Emits NOTHING when the env is
clean (zero token cost for normal users). Skipped entirely for remote
terminal backends (docker/modal/ssh) — they have their own probe.
Example output on a broken environment (the actual case):
Python toolchain: python3=3.11.15 (no pip module),
python=missing (use python3), pip→python3.12 (mismatch),
PEP 668=yes (use venv or uv).
## Config
Both flags live under `agent.` in config.yaml, default True:
agent:
task_completion_guidance: true # universal "finish the job" block
environment_probe: true # local Python toolchain hints
Neither addition required a `_config_version` bump — deep-merge fills
defaults in for existing user configs.
## Validation
| Test surface | Result |
|---|---|
| tests/tools/test_env_probe.py | 10/10 pass (probe unit) |
| tests/run_agent/test_run_agent.py — new classes | 8/8 pass (integration) |
| TestToolUseEnforcementConfig | 17/17 pass (no regression) |
| TestBuildSystemPrompt | 9/9 pass (no regression) |
| TestInvalidateSystemPrompt | 2/2 pass (no regression) |
| tests/agent/test_prompt_builder.py | 124/124 pass (no regression) |
| tests/hermes_cli/ | 5662/5662 pass (config defaults) |
| E2E AIAgent build (broken env) | Both blocks present, 2,178 chars |
| E2E AIAgent build (clean env) | 771-char net overhead, env probe silent |
2026-05-28 22:26:09 -07:00
|
|
|
def _format_responses_error(error_obj: Any, response_status: str) -> str:
|
|
|
|
|
"""Build a human-readable error string from a Responses ``response.error`` payload.
|
|
|
|
|
|
|
|
|
|
The OpenAI Responses API carries failure details under ``response.error``
|
|
|
|
|
on terminal ``response.failed`` events, in the shape
|
|
|
|
|
``{"code": "rate_limit_exceeded", "message": "Slow down", "param": ...}``.
|
|
|
|
|
Earlier code only surfaced ``message``, which left users staring at bare
|
|
|
|
|
strings like ``"Slow down"`` while the failure mode (rate limit vs
|
|
|
|
|
context-length vs internal_error vs model-overloaded) was hidden in
|
|
|
|
|
``code``. We now prefix ``code`` when both are present so consumers can
|
|
|
|
|
distinguish failure modes without parsing the bare message.
|
|
|
|
|
|
|
|
|
|
Falls back to ``code`` alone when ``message`` is empty, and to a stable
|
|
|
|
|
default referencing the response status when no error payload is
|
|
|
|
|
available at all. Adapted from anomalyco/opencode#28757.
|
|
|
|
|
"""
|
|
|
|
|
# Pull code and message from either dict or attribute-style payloads.
|
|
|
|
|
code: Any = None
|
|
|
|
|
message: Any = None
|
|
|
|
|
if isinstance(error_obj, dict):
|
|
|
|
|
code = error_obj.get("code")
|
|
|
|
|
message = error_obj.get("message")
|
|
|
|
|
elif error_obj is not None:
|
|
|
|
|
code = getattr(error_obj, "code", None)
|
|
|
|
|
message = getattr(error_obj, "message", None)
|
|
|
|
|
|
|
|
|
|
code_str = str(code).strip() if isinstance(code, str) else (str(code).strip() if code else "")
|
|
|
|
|
message_str = str(message).strip() if isinstance(message, str) else (str(message).strip() if message else "")
|
|
|
|
|
|
|
|
|
|
if code_str and message_str:
|
|
|
|
|
return f"{code_str}: {message_str}"
|
|
|
|
|
if message_str:
|
|
|
|
|
return message_str
|
|
|
|
|
if code_str:
|
|
|
|
|
return code_str
|
|
|
|
|
if error_obj:
|
|
|
|
|
# Last-resort: stringify whatever the provider sent so it's at least
|
|
|
|
|
# visible in logs/UI rather than silently swallowed.
|
|
|
|
|
return str(error_obj)
|
|
|
|
|
return f"Responses API returned status '{response_status}'"
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Full response normalization
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-27 02:30:43 -07:00
|
|
|
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`` (when provided) is stamped onto each reasoning item the
|
|
|
|
|
response yields, so future replays can detect when the active endpoint
|
|
|
|
|
differs from the one that minted the encrypted_content blob and drop
|
|
|
|
|
the item instead of triggering HTTP 400 invalid_encrypted_content.
|
|
|
|
|
"""
|
2026-04-20 05:17:44 -07:00
|
|
|
output = getattr(response, "output", None)
|
|
|
|
|
if not isinstance(output, list) or not output:
|
|
|
|
|
# The Codex backend can return empty output when the answer was
|
|
|
|
|
# delivered entirely via stream events. Check output_text as a
|
|
|
|
|
# last-resort fallback 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 = [SimpleNamespace(
|
|
|
|
|
type="message", role="assistant", status="completed",
|
|
|
|
|
content=[SimpleNamespace(type="output_text", text=out_text.strip())],
|
|
|
|
|
)]
|
|
|
|
|
response.output = output
|
|
|
|
|
else:
|
|
|
|
|
raise RuntimeError("Responses API returned no output items")
|
|
|
|
|
|
|
|
|
|
response_status = getattr(response, "status", None)
|
|
|
|
|
if isinstance(response_status, str):
|
|
|
|
|
response_status = response_status.strip().lower()
|
|
|
|
|
else:
|
|
|
|
|
response_status = None
|
|
|
|
|
|
|
|
|
|
if response_status in {"failed", "cancelled"}:
|
|
|
|
|
error_obj = getattr(response, "error", None)
|
feat(prompt): universal task-completion guidance + local Python toolchain probe (#34340)
* fix(codex): surface error code in Responses 'failed' status errors
When a Codex Responses turn ends with status=failed, the response carries
the failure details under `response.error` as
`{code, message, param, ...}`. The previous extractor pulled only
`message`, so users seeing a rate-limit failure got a bare "Slow down"
string indistinguishable from a generic stream truncation; an
internal_error with empty message degraded to a dict dump
("{'code': 'internal_error', 'message': ''}").
Extract a `_format_responses_error()` helper that:
- prefixes `code` when both code and message are present
(e.g. 'rate_limit_exceeded: Slow down')
- falls back to the bare `code` when message is empty
- accepts both dict and attribute-style payloads (SDK and JSON-RPC paths)
- preserves the prior status-only fallback when no error payload exists
Apply the same helper at the sibling site in
`codex_app_server_session.run_turn()` so codex-CLI subprocess turn
failures get the same treatment.
Tests:
- 8 new unit tests for `_format_responses_error` covering both shapes,
empty/missing fields, non-string fields, and the status-only fallback.
- 2 regression tests on `_normalize_codex_response` for failed status
with and without a code, asserting the exact RuntimeError message.
- All 3603 tests in tests/agent/ pass.
Adapted from anomalyco/opencode#28757.
* feat(prompt): universal task-completion guidance + local Python toolchain probe
Two cross-model failure modes get a single-line answer in the cached
system prompt. Both gated by config (default on), both add zero overhead
when not needed, both verified via real AIAgent prompt builds.
## What changed
`TASK_COMPLETION_GUIDANCE` — short prompt block applied to ALL models.
Targets two failure modes observed on a real Sarasota real-estate build
task: (1) Opus stopped after writing an 85-byte stub and gave a prose
response with finish_reason=stop on call #3 of 90; (2) DeepSeek pushed
through a PEP-668 wall, then returned fabricated listings instead of
admitting the blocker. Both behaviors are model-family-agnostic, so the
guidance lives outside the existing tool_use_enforcement gate (~192
tokens, paid once per session via prefix cache).
`tools/env_probe.py` — local Python toolchain probe. Detects
python3/pip/uv/PEP-668 state and emits ONE short line in the system
prompt when something is non-default. Emits NOTHING when the env is
clean (zero token cost for normal users). Skipped entirely for remote
terminal backends (docker/modal/ssh) — they have their own probe.
Example output on a broken environment (the actual case):
Python toolchain: python3=3.11.15 (no pip module),
python=missing (use python3), pip→python3.12 (mismatch),
PEP 668=yes (use venv or uv).
## Config
Both flags live under `agent.` in config.yaml, default True:
agent:
task_completion_guidance: true # universal "finish the job" block
environment_probe: true # local Python toolchain hints
Neither addition required a `_config_version` bump — deep-merge fills
defaults in for existing user configs.
## Validation
| Test surface | Result |
|---|---|
| tests/tools/test_env_probe.py | 10/10 pass (probe unit) |
| tests/run_agent/test_run_agent.py — new classes | 8/8 pass (integration) |
| TestToolUseEnforcementConfig | 17/17 pass (no regression) |
| TestBuildSystemPrompt | 9/9 pass (no regression) |
| TestInvalidateSystemPrompt | 2/2 pass (no regression) |
| tests/agent/test_prompt_builder.py | 124/124 pass (no regression) |
| tests/hermes_cli/ | 5662/5662 pass (config defaults) |
| E2E AIAgent build (broken env) | Both blocks present, 2,178 chars |
| E2E AIAgent build (clean env) | 771-char net overhead, env probe silent |
2026-05-28 22:26:09 -07:00
|
|
|
error_msg = _format_responses_error(error_obj, response_status)
|
2026-04-20 05:17:44 -07:00
|
|
|
raise RuntimeError(error_msg)
|
|
|
|
|
|
|
|
|
|
content_parts: List[str] = []
|
|
|
|
|
reasoning_parts: List[str] = []
|
|
|
|
|
reasoning_items_raw: List[Dict[str, Any]] = []
|
2026-04-25 23:14:12 +03:00
|
|
|
message_items_raw: List[Dict[str, Any]] = []
|
2026-04-20 05:17:44 -07:00
|
|
|
tool_calls: List[Any] = []
|
|
|
|
|
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
2026-06-13 13:31:26 -07:00
|
|
|
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
|
2026-04-20 05:17:44 -07:00
|
|
|
saw_commentary_phase = False
|
|
|
|
|
saw_final_answer_phase = False
|
2026-05-13 16:03:26 +02:00
|
|
|
saw_reasoning_item = False
|
2026-04-20 05:17:44 -07:00
|
|
|
|
2026-06-11 11:06:01 -04:00
|
|
|
# Server-side built-in tool calls (xAI's native web_search, code
|
|
|
|
|
# interpreter, etc.) are executed by the provider and reported as
|
|
|
|
|
# discrete ``*_call`` output items. xAI's /v1/responses surface
|
|
|
|
|
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
|
|
|
|
|
# these items at ``status="in_progress"`` even when the overall
|
|
|
|
|
# ``response.status == "completed"`` — the search ran to completion
|
|
|
|
|
# server-side, the per-item status simply isn't reconciled. These
|
|
|
|
|
# are NOT a signal that the model's turn is unfinished, so they must
|
|
|
|
|
# not flip ``has_incomplete_items``. Only the response-level status
|
|
|
|
|
# and genuine model output items (message/reasoning/function_call)
|
|
|
|
|
# govern the incomplete verdict. Without this guard, any turn where
|
|
|
|
|
# grok-composer invokes server-side search is misclassified as
|
|
|
|
|
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
|
|
|
|
|
# retries before failing with "Codex response remained incomplete
|
|
|
|
|
# after 3 continuation attempts". client-side function/custom tool
|
|
|
|
|
# calls keep their own in_progress handling below (they are skipped,
|
|
|
|
|
# not awaited).
|
|
|
|
|
_SERVER_SIDE_TOOL_CALL_TYPES = {
|
|
|
|
|
"web_search_call",
|
|
|
|
|
"file_search_call",
|
|
|
|
|
"code_interpreter_call",
|
|
|
|
|
"image_generation_call",
|
|
|
|
|
"computer_call",
|
|
|
|
|
"local_shell_call",
|
|
|
|
|
"mcp_call",
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
for item in output:
|
|
|
|
|
item_type = getattr(item, "type", None)
|
|
|
|
|
item_status = getattr(item, "status", None)
|
|
|
|
|
if isinstance(item_status, str):
|
|
|
|
|
item_status = item_status.strip().lower()
|
|
|
|
|
else:
|
|
|
|
|
item_status = None
|
|
|
|
|
|
2026-06-11 11:06:01 -04:00
|
|
|
if (
|
|
|
|
|
item_status in {"queued", "in_progress", "incomplete"}
|
|
|
|
|
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
|
|
|
|
|
):
|
2026-04-20 05:17:44 -07:00
|
|
|
has_incomplete_items = True
|
2026-06-13 13:31:26 -07:00
|
|
|
saw_streaming_or_item_incomplete = True
|
2026-04-20 05:17:44 -07:00
|
|
|
|
|
|
|
|
if item_type == "message":
|
|
|
|
|
item_phase = getattr(item, "phase", None)
|
2026-04-25 23:14:12 +03:00
|
|
|
normalized_phase = None
|
2026-04-20 05:17:44 -07:00
|
|
|
if isinstance(item_phase, str):
|
|
|
|
|
normalized_phase = item_phase.strip().lower()
|
|
|
|
|
if normalized_phase in {"commentary", "analysis"}:
|
|
|
|
|
saw_commentary_phase = True
|
|
|
|
|
elif normalized_phase in {"final_answer", "final"}:
|
|
|
|
|
saw_final_answer_phase = True
|
|
|
|
|
message_text = _extract_responses_message_text(item)
|
|
|
|
|
if message_text:
|
|
|
|
|
content_parts.append(message_text)
|
2026-04-25 23:14:12 +03:00
|
|
|
raw_message_item: Dict[str, Any] = {
|
|
|
|
|
"type": "message",
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"status": _normalize_responses_message_status(item_status),
|
|
|
|
|
"content": [{"type": "output_text", "text": message_text}],
|
|
|
|
|
}
|
|
|
|
|
item_id = getattr(item, "id", None)
|
|
|
|
|
if isinstance(item_id, str) and item_id:
|
|
|
|
|
raw_message_item["id"] = item_id
|
|
|
|
|
if normalized_phase:
|
|
|
|
|
raw_message_item["phase"] = normalized_phase
|
|
|
|
|
message_items_raw.append(raw_message_item)
|
2026-04-20 05:17:44 -07:00
|
|
|
elif item_type == "reasoning":
|
2026-05-13 16:03:26 +02:00
|
|
|
saw_reasoning_item = True
|
2026-04-20 05:17:44 -07:00
|
|
|
reasoning_text = _extract_responses_reasoning_text(item)
|
|
|
|
|
if reasoning_text:
|
|
|
|
|
reasoning_parts.append(reasoning_text)
|
|
|
|
|
# Capture the full reasoning item for multi-turn continuity.
|
|
|
|
|
# encrypted_content is an opaque blob the API needs back on
|
|
|
|
|
# subsequent turns to maintain coherent reasoning chains.
|
|
|
|
|
encrypted = getattr(item, "encrypted_content", None)
|
|
|
|
|
if isinstance(encrypted, str) and encrypted:
|
|
|
|
|
raw_item = {"type": "reasoning", "encrypted_content": encrypted}
|
2026-05-27 02:30:43 -07:00
|
|
|
# Stamp the issuer so future turns can detect when a
|
|
|
|
|
# model swap moved the conversation to an endpoint that
|
|
|
|
|
# cannot decrypt this blob — see _chat_messages_to_responses_input
|
|
|
|
|
# cross-issuer guard.
|
|
|
|
|
if issuer_kind:
|
|
|
|
|
raw_item["_issuer_kind"] = issuer_kind
|
2026-04-20 05:17:44 -07:00
|
|
|
item_id = getattr(item, "id", None)
|
2026-05-13 16:03:26 +02:00
|
|
|
if isinstance(item_id, str) and item_id.startswith("rs_tmp_"):
|
|
|
|
|
logger.debug(
|
|
|
|
|
"Skipping transient Codex reasoning item during normalization: %s",
|
|
|
|
|
item_id,
|
|
|
|
|
)
|
|
|
|
|
continue
|
2026-04-20 05:17:44 -07:00
|
|
|
if isinstance(item_id, str) and item_id:
|
|
|
|
|
raw_item["id"] = item_id
|
|
|
|
|
# Capture summary — required by the API when replaying reasoning items
|
|
|
|
|
summary = getattr(item, "summary", None)
|
|
|
|
|
if isinstance(summary, list):
|
|
|
|
|
raw_summary = []
|
|
|
|
|
for part in summary:
|
|
|
|
|
text = getattr(part, "text", None)
|
|
|
|
|
if isinstance(text, str):
|
|
|
|
|
raw_summary.append({"type": "summary_text", "text": text})
|
|
|
|
|
raw_item["summary"] = raw_summary
|
|
|
|
|
reasoning_items_raw.append(raw_item)
|
|
|
|
|
elif item_type == "function_call":
|
|
|
|
|
if item_status in {"queued", "in_progress", "incomplete"}:
|
|
|
|
|
continue
|
|
|
|
|
fn_name = getattr(item, "name", "") or ""
|
|
|
|
|
arguments = getattr(item, "arguments", "{}")
|
|
|
|
|
if not isinstance(arguments, str):
|
|
|
|
|
arguments = json.dumps(arguments, ensure_ascii=False)
|
|
|
|
|
raw_call_id = getattr(item, "call_id", None)
|
|
|
|
|
raw_item_id = getattr(item, "id", None)
|
|
|
|
|
embedded_call_id, _ = _split_responses_tool_id(raw_item_id)
|
|
|
|
|
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
|
|
|
|
if not isinstance(call_id, str) or not call_id.strip():
|
|
|
|
|
call_id = _deterministic_call_id(fn_name, arguments, len(tool_calls))
|
|
|
|
|
call_id = call_id.strip()
|
|
|
|
|
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
|
|
|
|
response_item_id = _derive_responses_function_call_id(call_id, response_item_id)
|
|
|
|
|
tool_calls.append(SimpleNamespace(
|
|
|
|
|
id=call_id,
|
|
|
|
|
call_id=call_id,
|
|
|
|
|
response_item_id=response_item_id,
|
|
|
|
|
type="function",
|
|
|
|
|
function=SimpleNamespace(name=fn_name, arguments=arguments),
|
|
|
|
|
))
|
|
|
|
|
elif item_type == "custom_tool_call":
|
|
|
|
|
fn_name = getattr(item, "name", "") or ""
|
|
|
|
|
arguments = getattr(item, "input", "{}")
|
|
|
|
|
if not isinstance(arguments, str):
|
|
|
|
|
arguments = json.dumps(arguments, ensure_ascii=False)
|
|
|
|
|
raw_call_id = getattr(item, "call_id", None)
|
|
|
|
|
raw_item_id = getattr(item, "id", None)
|
|
|
|
|
embedded_call_id, _ = _split_responses_tool_id(raw_item_id)
|
|
|
|
|
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
|
|
|
|
if not isinstance(call_id, str) or not call_id.strip():
|
|
|
|
|
call_id = _deterministic_call_id(fn_name, arguments, len(tool_calls))
|
|
|
|
|
call_id = call_id.strip()
|
|
|
|
|
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
|
|
|
|
response_item_id = _derive_responses_function_call_id(call_id, response_item_id)
|
|
|
|
|
tool_calls.append(SimpleNamespace(
|
|
|
|
|
id=call_id,
|
|
|
|
|
call_id=call_id,
|
|
|
|
|
response_item_id=response_item_id,
|
|
|
|
|
type="function",
|
|
|
|
|
function=SimpleNamespace(name=fn_name, arguments=arguments),
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
final_text = "\n".join([p for p in content_parts if p]).strip()
|
|
|
|
|
if not final_text and hasattr(response, "output_text"):
|
|
|
|
|
out_text = getattr(response, "output_text", "")
|
|
|
|
|
if isinstance(out_text, str):
|
|
|
|
|
final_text = out_text.strip()
|
|
|
|
|
|
2026-04-24 14:39:59 -07:00
|
|
|
# ── Tool-call leak recovery ──────────────────────────────────
|
|
|
|
|
# gpt-5.x on the Codex Responses API sometimes degenerates and emits
|
|
|
|
|
# what should be a structured `function_call` item as plain assistant
|
|
|
|
|
# text using the Harmony/Codex serialization (``to=functions.foo
|
|
|
|
|
# {json}`` or ``assistant to=functions.foo {json}``). The model
|
|
|
|
|
# intended to call a tool, but the intent never made it into
|
|
|
|
|
# ``response.output`` as a ``function_call`` item, so ``tool_calls``
|
|
|
|
|
# is empty here. If we pass this through, the parent sees a
|
|
|
|
|
# confident-looking summary with no audit trail (empty ``tool_trace``)
|
|
|
|
|
# and no tools actually ran — the Taiwan-embassy-email incident.
|
|
|
|
|
#
|
|
|
|
|
# Detection: leaked tokens always contain ``to=functions.<name>`` and
|
|
|
|
|
# the assistant message has no real tool calls. Treat it as incomplete
|
|
|
|
|
# so the existing Codex-incomplete continuation path (3 retries,
|
|
|
|
|
# handled in run_agent.py) gets a chance to re-elicit a proper
|
|
|
|
|
# ``function_call`` item. The existing loop already handles message
|
|
|
|
|
# append, dedup, and retry budget.
|
|
|
|
|
leaked_tool_call_text = False
|
|
|
|
|
if final_text and not tool_calls and _TOOL_CALL_LEAK_PATTERN.search(final_text):
|
|
|
|
|
leaked_tool_call_text = True
|
|
|
|
|
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],
|
|
|
|
|
)
|
|
|
|
|
# Clear the text so downstream code doesn't surface the garbage as
|
|
|
|
|
# a summary. The encrypted reasoning items (if any) are preserved
|
|
|
|
|
# so the model keeps its chain-of-thought on the retry.
|
|
|
|
|
final_text = ""
|
|
|
|
|
|
2026-04-20 05:17:44 -07:00
|
|
|
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,
|
2026-04-25 23:14:12 +03:00
|
|
|
codex_message_items=message_items_raw or None,
|
2026-04-20 05:17:44 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tool_calls:
|
|
|
|
|
finish_reason = "tool_calls"
|
2026-04-24 14:39:59 -07:00
|
|
|
elif leaked_tool_call_text:
|
|
|
|
|
finish_reason = "incomplete"
|
2026-06-13 13:31:26 -07:00
|
|
|
elif saw_streaming_or_item_incomplete:
|
|
|
|
|
finish_reason = "incomplete"
|
|
|
|
|
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
|
2026-04-20 05:17:44 -07:00
|
|
|
finish_reason = "incomplete"
|
2026-05-13 16:03:26 +02:00
|
|
|
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
|
|
|
|
|
# Response contains only reasoning (encrypted thinking state and/or
|
|
|
|
|
# human-readable summary) with no visible content or tool calls. The
|
|
|
|
|
# model is still thinking and needs another turn to produce the actual
|
|
|
|
|
# answer. Marking this as "stop" would send it into the empty-content
|
|
|
|
|
# retry loop which burns retries then fails — treat it as incomplete so
|
|
|
|
|
# the Codex continuation path handles it correctly.
|
2026-04-20 05:17:44 -07:00
|
|
|
finish_reason = "incomplete"
|
|
|
|
|
else:
|
|
|
|
|
finish_reason = "stop"
|
|
|
|
|
return assistant_message, finish_reason
|