Files
hermes-agent/tests/agent/test_codex_incomplete_budget_escalation.py
PRATHAMESH75 f7567a62af fix: hand Codex reasoning-only stalls to the fallback provider instead of the incomplete sentinel
Three consecutive Codex Responses answers that carry only (encrypted) reasoning —
no visible text, no tool call — used to exhaust the 3-continuation budget and end
the turn on "Codex response remained incomplete after 3 continuation attempts",
never touching configured fallback_providers (#67321). Encrypted reasoning items
replay byte-for-byte, so a bare retry deterministically repeats the stall.

- Track a per-turn `_codex_reasoning_only_streak` apart from the aggregate
  `_codex_incomplete_retries`: a visible partial resets the streak, so the mixed
  partial-then-stall variant still reaches its own recovery threshold while the
  turn-wide iteration budget stays the hard bound.
- At streak 3, `continue_codex_incomplete` activates the next fallback with the
  semantic `FailoverReason.incomplete_response`, grants exactly one grace call when
  the trigger consumed the last iteration, and returns `CODEX_FALLBACK_ACTIVATED`;
  the intake re-syncs the Model:/Provider: identity on the system prompt.
- Off the Codex wire the synthetic continuation nudge is stripped alongside the
  opaque replay state (`drop_nudge_marker`) so the Chat Completions payload keeps
  valid role ordering and no Codex-only control text.
- No fallback configured: unchanged terminal sentinel, still bounded at 3 calls.

Ported from PR #67336 by @PRATHAMESH75 onto the decomposed agent/turn_*.py siblings.
2026-09-19 00:04:42 -07:00

80 lines
3.5 KiB
Python

"""Responses-wire ``status=incomplete`` continuation must change the request when reasoning
consumed the whole output budget (#90393): a retry with the same ``max_output_tokens`` and the
same effort re-burns the budget identically and the turn can never converge. A fragment that
already carries visible text is a normal partial answer and keeps today's bare replay; with no
configured cap the escalation seeds from the ceiling the provider reported."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from agent.turn_truncation import continue_codex_incomplete
def _agent(max_tokens: int | None = 2000):
agent = MagicMock()
agent.max_tokens = max_tokens
agent.quiet_mode = True
agent.log_prefix = ""
agent._codex_incomplete_retries = 0
agent._codex_reasoning_only_streak = 0
agent._ephemeral_reasoning_off = False
agent._ephemeral_max_output_tokens = None
agent._build_assistant_message.side_effect = lambda msg, fr: {
"role": "assistant", "content": msg.content or "", "finish_reason": fr,
"codex_reasoning_items": msg.codex_reasoning_items,
}
agent._interim_assistant_visible_text.return_value = ""
return agent
def _reasoning_only_message():
return SimpleNamespace(
content="", tool_calls=None, reasoning=None,
codex_reasoning_items=[{"type": "reasoning", "id": "rs_1", "encrypted_content": "x"}],
)
def _run(agent, response):
messages = [{"role": "user", "content": "write an essay"}]
return continue_codex_incomplete(
agent, _reasoning_only_message(), "incomplete", messages=messages,
conversation_history=None, api_call_count=1, response=response,
)
def test_budget_exhausted_empty_fragment_raises_cap_and_drops_reasoning():
agent = _agent()
exhausted = SimpleNamespace(
status="incomplete", incomplete_details={"reason": "max_output_tokens"},
usage=SimpleNamespace(output_tokens=2000, output_tokens_details={"reasoning_tokens": 1997}),
)
assert _run(agent, exhausted) is None
assert agent._ephemeral_reasoning_off is True
first_boost = agent._ephemeral_max_output_tokens
assert first_boost > 2000
agent._ephemeral_max_output_tokens = None # request builder consumes it
assert _run(agent, exhausted) is None
assert agent._ephemeral_max_output_tokens > first_boost
def test_no_configured_cap_seeds_escalation_from_observed_ceiling():
agent = _agent(max_tokens=None)
exhausted = SimpleNamespace(
status="incomplete", incomplete_details={"reason": "max_output_tokens"},
usage=SimpleNamespace(output_tokens=6000, output_tokens_details={"reasoning_tokens": 5997}),
)
assert _run(agent, exhausted) is None
assert agent._ephemeral_max_output_tokens > 6000
# A fragment that carries visible text is a normal partial answer: no override, today's replay.
agent = _agent()
agent._interim_assistant_visible_text.return_value = "First paragraph of the essay"
messages = [{"role": "user", "content": "write an essay"}]
partial = SimpleNamespace(content="First paragraph of the essay", tool_calls=None, reasoning=None,
codex_reasoning_items=None)
continue_codex_incomplete(agent, partial, "incomplete", messages=messages,
conversation_history=None, api_call_count=1, response=exhausted)
assert agent._ephemeral_reasoning_off is False
assert agent._ephemeral_max_output_tokens is None