diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index e5acd381..32255892 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -31,7 +31,6 @@ from backend.apps.agents.core.error_classify import ( from backend.apps.agents.core.is_router_unavailable_error import is_router_unavailable_error from backend.apps.agents.core.extract_reset_hint import extract_reset_hint from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry -from backend.apps.agents.manager.run.RunOptions import PREFIX_NARROWNESS from backend.apps.agents.core import flight_recorder from backend.apps.agents.manager.run.empty_finish import count_tool_calls from backend.apps.agents.session_credential import api_key_twin_model @@ -377,23 +376,14 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str, # The provider's abuse classifier declined the REQUEST, and on the subscription lane what it reads as "duplicating model outputs" is OUR recap of the chat on a fresh CLI session (192 blocks in 14 days, 0 on API keys); deterministic, so the one retry that can pass carries no history at all, the session stays that way, and every block is reported with the shape it sent. p_sent = session.history_prefix_sent p_report_model_error(f"policy_block:{p_sent}", session_id, session, turn, e, p_stderr_tail) - # Ratchet on the DECLARED mode, never on what happened to be rendered. `history_prefix_sent` - # defaults to "none" on every turn and is only overwritten when a fresh-session rebuild - # actually attaches a recap, so on a RESUMED turn (the common case) it reads "none" while the - # mode is still "minimal". Keying the ladder on it meant a block on a resumed turn skipped the - # ratchet entirely, went straight to "nothing left to strip", and left the mode untouched, so - # the next rebuild sent a recap again and blocked again. Live: one chat blocked 3x with - # sent going none -> minimal -> none, which ENG-399 says can never happen (2026-08-30). - p_mode = session.history_prefix_mode - if p_mode != "none": - p_next = PREFIX_NARROWNESS[PREFIX_NARROWNESS.index(p_mode) + 1] - session.history_prefix_mode = p_next + if p_sent != "none": + session.history_prefix_mode = "none" session.needs_fresh_session = True session.pending_continuation = True session.pending_continuation_prompt = ( "Continue the task exactly where you left off; the session summary was reduced " "this turn, rely on the visible conversation.") - logger.warning(f"Agent {session_id}: provider content-policy block (sent={p_sent}); narrowing the recap {p_mode} -> {session.history_prefix_mode} and retrying") + logger.warning(f"Agent {session_id}: provider content-policy block on a turn carrying a {p_sent} history prefix; retrying with {session.history_prefix_mode}") return # The subscription lane declined and nothing is left to strip; fleet data says the same request passes on an API key (0 of 328 vs 4.4%), so a user who connected their own Anthropic key continues there, told in one line, instead of losing the ask (ENG-383). p_twin = api_key_twin_model(session.model or "", load_settings()) diff --git a/backend/tests/test_policy_ratchet_uses_declared_mode.py b/backend/tests/test_policy_ratchet_uses_declared_mode.py deleted file mode 100644 index 0d291813..00000000 --- a/backend/tests/test_policy_ratchet_uses_declared_mode.py +++ /dev/null @@ -1,84 +0,0 @@ -"""A blocked chat retried, blocked, retried, blocked (Ken, 2026-08-31; envelopes 2026-08-30). - -The ratchet keyed on `history_prefix_sent`, which is an OUTCOME: RunOptions sets it to "none" at the -top of every turn and only overwrites it when a fresh-session rebuild actually attaches a recap. On a -RESUMED turn nothing is attached, so it reads "none" while the declared mode is still "minimal". - -The handler read that "none" as "nothing left to strip", skipped the ladder, and went straight to the -terminal card WITHOUT narrowing anything. The next rebuild therefore sent a recap again and blocked -again. The fleet shows the impossible sequence that proves it: one session, three blocks, sent going -none -> minimal -> none, when ENG-399 says a session ratcheted to none is never widened back. - -Rule this restores: never key a guard on an incidental fact; use the DECLARED signal. -""" -import pytest - -from backend.apps.agents.core.models import AgentSession -from backend.apps.agents.manager.run.RunOptions import PREFIX_NARROWNESS - - -def p_session(mode, sent): - s = AgentSession(name="t", model="sonnet", dashboard_id="d") - s.history_prefix_mode = mode - s.history_prefix_sent = sent - return s - - -def test_sent_is_an_outcome_not_the_state(): - """The field the old code trusted defaults to 'none' on a turn that simply resumed.""" - import inspect - from backend.apps.agents.manager.run import RunOptions - src = inspect.getsource(RunOptions) - assert 'session.history_prefix_sent = "none"' in src, ( - "if this default ever goes away, the bug's premise changes and this file should be re-read" - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("mode,expected", [("minimal", "summary"), ("summary", "none")]) -async def test_a_block_on_a_RESUMED_turn_still_narrows(monkeypatch, mode, expected): - """sent='none' (nothing attached) while the mode is wider: the ladder MUST still step.""" - from backend.apps.agents.manager.run import handle_run_error as mod - from backend.apps.agents.manager.streaming.state import TurnState - - async def p_send(*a, **k): - return None - monkeypatch.setattr(mod.ws_manager, "send_to_session", p_send) - monkeypatch.setattr(mod, "p_report_model_error", lambda *a, **k: None) - - s = p_session(mode, "none") - err = RuntimeError( - 'API Error: 400 {"error":{"message":"Output blocked as it seems to violate our ' - 'Acceptable Use Policy (legal/aup): reverse engineering or duplicating model outputs"}}' - ) - await mod.handle_run_error(err, s, s.id, TurnState(), []) - - assert s.history_prefix_mode == expected, "the declared mode must narrow one step" - assert s.pending_continuation is True, "and the narrowed retry must actually be armed" - assert [m for m in s.messages if m.role == "system"] == [], "no terminal card while the ladder has room" - - -@pytest.mark.asyncio -async def test_only_an_EXHAUSTED_ladder_reaches_the_terminal_card(monkeypatch): - from backend.apps.agents.manager.run import handle_run_error as mod - from backend.apps.agents.manager.streaming.state import TurnState - - async def p_send(*a, **k): - return None - monkeypatch.setattr(mod.ws_manager, "send_to_session", p_send) - monkeypatch.setattr(mod, "p_report_model_error", lambda *a, **k: None) - monkeypatch.setattr(mod, "api_key_twin_model", lambda *a, **k: None) # no own key, like the field - - s = p_session("none", "none") - await mod.handle_run_error(RuntimeError("blocked ... legal/aup ... duplicating model outputs"), - s, s.id, TurnState(), []) - cards = [m for m in s.messages if m.role == "system"] - assert cards, "with nothing left to strip the user is owed the honest card" - assert "start a fresh chat" in cards[-1].content.lower() - assert s.pending_continuation is False, "and it must NOT keep retrying a request that cannot pass" - - -def test_the_ladder_only_ever_narrows(): - assert PREFIX_NARROWNESS == ("minimal", "summary", "none") - for i, mode in enumerate(PREFIX_NARROWNESS[:-1]): - assert PREFIX_NARROWNESS.index(PREFIX_NARROWNESS[i + 1]) > PREFIX_NARROWNESS.index(mode)