diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index 32255892..e5acd381 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -31,6 +31,7 @@ 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 @@ -376,14 +377,23 @@ 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) - if p_sent != "none": - session.history_prefix_mode = "none" + # 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 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 on a turn carrying a {p_sent} history prefix; retrying with {session.history_prefix_mode}") + logger.warning(f"Agent {session_id}: provider content-policy block (sent={p_sent}); narrowing the recap {p_mode} -> {session.history_prefix_mode} and retrying") 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 new file mode 100644 index 00000000..0d291813 --- /dev/null +++ b/backend/tests/test_policy_ratchet_uses_declared_mode.py @@ -0,0 +1,84 @@ +"""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)