fix(codex): rescue reasoning-only turns that die with 'remained incomplete after 3 continuation attempts'

grok-4.x on the xAI /v1/responses surface sometimes ends a turn with only
reasoning items — no message output item, no tool calls — and those
reasoning items carry no encrypted_content. Two compounding problems:

1. The model occasionally emits its final answer INSIDE the reasoning
   channel, delimited by grok's internal "<response>" tag. The answer
   exists but is classified reasoning-only → finish_reason=incomplete.

2. An interim assistant message holding only plain-text reasoning replays
   as nothing in _chat_messages_to_responses_input, so every continuation
   request is byte-identical to the one that just failed. The model
   deterministically repeats the reasoning-only response until the retry
   budget is exhausted and the turn dies with "Codex response remained
   incomplete after 3 continuation attempts".

Fixes:
- _normalize_codex_response (xai_responses only): salvage the
  <response>-delimited tail from the reasoning text and promote it to
  assistant content; the untagged prefix stays as thinking text.
- Codex-incomplete continuation path: when the interim message has
  nothing the input converter will replay (no content, no encrypted
  reasoning items, no message items), append a user-role nudge so the
  retry actually differs and explicitly asks for the final answer /
  pending tool call. Mirrors the existing _get_continuation_prompt
  pattern used for length truncation.

Observed live with grok-4.20 on xai-oauth (2026-07-13); sibling of the
grok-composer web_search incomplete-loop fix in transports/codex.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ignacio Pastor 2026-07-13 11:07:08 +02:00 committed by Teknium
parent 07443ea21a
commit 05d1ca549b
4 changed files with 228 additions and 0 deletions

View file

@ -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,

View file

@ -457,6 +457,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
@ -4469,6 +4484,33 @@ def run_conversation(
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

View file

@ -437,3 +437,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 == ""

View file

@ -2875,3 +2875,72 @@ def test_run_conversation_codex_invalid_encrypted_content_without_replay_state_d
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
)