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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions agent/codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,45 @@ def _normalize_codex_response(
# so the model keeps its chain-of-thought on the retry.
final_text = ""

# ── Reasoning-channel answer salvage (xAI grok) ──────────────
# grok-4.x on the xAI /v1/responses surface sometimes emits its final
# answer inside the reasoning item instead of as a ``message`` output
# item, marking where the answer starts with grok's internal
# ``<response>`` delimiter. Without salvage, the reasoning-only rule
# below classifies the turn ``incomplete`` — and because reasoning
# items on this surface carry no ``encrypted_content``, the interim
# message replays as nothing, so every continuation request is
# byte-identical to the one that just failed. The turn burns its 3
# retries and dies with "Codex response remained incomplete after 3
# continuation attempts" even though the answer was produced on the
# first attempt. Observed live with grok-4.20 on xai-oauth
# (2026-07-13). Promote the delimited tail to assistant content and
# keep the untagged prefix as thinking text.
if (
issuer_kind == "xai_responses"
and not final_text
and not tool_calls
and reasoning_parts
):
joined_reasoning = "\n\n".join(reasoning_parts)
marker = joined_reasoning.rfind("<response>")
if marker != -1:
salvaged = joined_reasoning[marker + len("<response>"):]
closing = salvaged.find("</response>")
if closing != -1:
salvaged = salvaged[:closing]
salvaged = salvaged.strip()
if salvaged:
logger.warning(
"xAI response delivered its final answer inside the "
"reasoning channel (<response> delimiter); promoting "
"%d chars to assistant content.",
len(salvaged),
)
final_text = salvaged
reasoning_prefix = joined_reasoning[:marker].strip()
reasoning_parts = [reasoning_prefix] if reasoning_prefix else []

assistant_message = SimpleNamespace(
content=final_text,
tool_calls=tool_calls,
Expand Down
42 changes: 42 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,21 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
)


# Continuation nudge for Codex/Responses turns that came back with only
# internal reasoning (no visible content, no tool calls). When the interim
# assistant message also carries no encrypted reasoning items and no
# replayable message items, _chat_messages_to_responses_input emits nothing
# for it — a bare retry would be byte-identical to the request that just
# failed, so the model (observed: grok-4.20 on xai-oauth) deterministically
# repeats the reasoning-only response until the retry budget is exhausted.
_CODEX_INCOMPLETE_NUDGE = (
"[System: Your previous response contained only internal reasoning and "
"never produced a visible answer or tool call. Do not keep thinking. "
"Produce your final answer as plain text now (or make the tool call "
"you were planning).]"
)


# Shared recovery hint appended to every content-policy refusal message. Both
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
# exception path (a provider moderation error classified as
Expand Down Expand Up @@ -4425,6 +4440,33 @@ def _perform_api_call(next_api_kwargs):
agent._emit_interim_assistant_message(interim_msg)

if agent._codex_incomplete_retries < 3:
# When the interim message has nothing the Responses
# input converter will replay (no visible content, no
# encrypted reasoning items, no replayable message
# items — plain-text reasoning only), a bare retry is
# byte-identical to the request that just came back
# incomplete and fails the same way every time
# (observed with grok-4.20 on xai-oauth, whose
# reasoning items lack encrypted_content). Append a
# user-role nudge so the retry actually differs and
# explicitly asks for the final answer.
interim_replayable = (
interim_has_content
or interim_has_codex_reasoning
or interim_has_codex_message_items
)
if not interim_replayable:
_last_msg = messages[-1] if messages else None
_already_nudged = (
isinstance(_last_msg, dict)
and _last_msg.get("role") == "user"
and _last_msg.get("content") == _CODEX_INCOMPLETE_NUDGE
)
if not _already_nudged:
messages.append({
"role": "user",
"content": _CODEX_INCOMPLETE_NUDGE,
})
if not agent.quiet_mode:
agent._vprint(f"{agent.log_prefix}↻ Codex response incomplete; continuing turn ({agent._codex_incomplete_retries}/3)")
agent._session_messages = messages
Expand Down
78 changes: 78 additions & 0 deletions tests/agent/test_codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,81 @@ def test_normalize_codex_response_failed_with_message_only():
)
with pytest.raises(RuntimeError, match=r"^model error$"):
_normalize_codex_response(response)


# ---------------------------------------------------------------------------
# Reasoning-channel answer salvage (xAI grok) — grok-4.x on the xAI
# /v1/responses surface sometimes emits its final answer inside the
# reasoning item, delimited by grok's internal "<response>" tag, with no
# ``message`` output item at all. Because those reasoning items carry no
# encrypted_content, the interim message replays as nothing and every
# continuation request is byte-identical — the turn burns 3 retries and
# fails even though the answer was produced. Observed live with grok-4.20
# on xai-oauth (2026-07-13).
# ---------------------------------------------------------------------------


def _xai_reasoning_only_response(reasoning_text):
return SimpleNamespace(
status="completed",
output=[
SimpleNamespace(
type="reasoning",
id="rs_1",
encrypted_content=None,
summary=[SimpleNamespace(text=reasoning_text)],
)
],
)


def test_normalize_codex_response_salvages_xai_reasoning_channel_answer():
response = _xai_reasoning_only_response(
"The process is still running.\n<response>\nAll good, process running."
)

assistant_message, finish_reason = _normalize_codex_response(
response, issuer_kind="xai_responses"
)

assert finish_reason == "stop"
assert assistant_message.content == "All good, process running."
assert assistant_message.reasoning == "The process is still running."


def test_normalize_codex_response_salvage_strips_closing_tag():
response = _xai_reasoning_only_response(
"Thinking.\n<response>The answer.</response>"
)

assistant_message, finish_reason = _normalize_codex_response(
response, issuer_kind="xai_responses"
)

assert finish_reason == "stop"
assert assistant_message.content == "The answer."


def test_normalize_codex_response_salvage_is_xai_scoped():
"""Non-xAI issuers keep the reasoning-only → incomplete classification;
the Codex backend replays encrypted reasoning, so its continuation
genuinely progresses and must not be short-circuited."""
response = _xai_reasoning_only_response(
"Thinking.\n<response>The answer.</response>"
)

assistant_message, finish_reason = _normalize_codex_response(response)

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


def test_normalize_codex_response_xai_reasoning_without_marker_stays_incomplete():
response = _xai_reasoning_only_response("Still thinking, no answer yet.")

assistant_message, finish_reason = _normalize_codex_response(
response, issuer_kind="xai_responses"
)

assert finish_reason == "incomplete"
assert assistant_message.content == ""
69 changes: 69 additions & 0 deletions tests/run_agent/test_run_agent_codex_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2806,3 +2806,72 @@ def _fake_api_call(api_kwargs):
assert all(not any(item.get("type") == "reasoning" for item in payload["input"]) for payload in request_payloads)
assert agent._codex_reasoning_replay_enabled is True
assert result["messages"][0].get("codex_reasoning_items") is None


def test_run_conversation_codex_nudges_after_unreplayable_reasoning_only_interim(monkeypatch):
"""A reasoning-only interim with NO encrypted_content (the shape
grok-4.20 on xai-oauth returns when it never emits a message output
item) replays as nothing — without a nudge every continuation request
is byte-identical to the one that just came back incomplete."""
agent = _build_agent(monkeypatch)
requests = []
responses = [
_codex_reasoning_only_response(
encrypted_content=None,
summary_text="Thinking about the repo structure...",
),
_codex_message_response("Final answer."),
]

def _fake_api_call(api_kwargs):
requests.append(api_kwargs)
return responses.pop(0)

monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call)

result = agent.run_conversation("analyze repo")

assert result["completed"] is True
assert result["final_response"] == "Final answer."
assert len(requests) == 2

replay_input = requests[1]["input"]
nudges = [
item for item in replay_input
if isinstance(item, dict)
and item.get("role") == "user"
and "only internal reasoning" in str(item.get("content"))
]
assert len(nudges) == 1, (
"Continuation after an unreplayable reasoning-only interim must "
"append the nudge user message; otherwise the retry request is "
"identical to the one that just failed."
)


def test_run_conversation_codex_no_nudge_for_replayable_interim(monkeypatch):
"""An interim that carries visible content replays fine — the nudge
must not fire and pollute the conversation."""
agent = _build_agent(monkeypatch)
requests = []
responses = [
_codex_incomplete_message_response("Partial visible content."),
_codex_message_response("Done."),
]

def _fake_api_call(api_kwargs):
requests.append(api_kwargs)
return responses.pop(0)

monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call)

result = agent.run_conversation("analyze repo")

assert result["completed"] is True
replay_input = requests[1]["input"]
assert not any(
isinstance(item, dict)
and item.get("role") == "user"
and "only internal reasoning" in str(item.get("content"))
for item in replay_input
)