diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 1d14148c7aeb..f3f5ec3da97f 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -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 + # ```` 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("") + if marker != -1: + salvaged = joined_reasoning[marker + len(""):] + closing = salvaged.find("") + if closing != -1: + salvaged = salvaged[:closing] + salvaged = salvaged.strip() + if salvaged: + logger.warning( + "xAI response delivered its final answer inside the " + "reasoning channel ( 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, diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 5a81d2f7793e..90173395ded9 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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 diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index 4e8a25a661ba..4a14cadf4a3d 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -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 "" 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\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.\nThe answer." + ) + + 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.\nThe answer." + ) + + 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 == "" diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 6aa381dd2321..93823144a19b 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -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 + )