mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
07443ea21a
commit
05d1ca549b
4 changed files with 228 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue