From a66b0b0eaad9ee22b93b8f282bd8df5d64bb3bc6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 19 Aug 2026 11:32:29 -0700 Subject: [PATCH] [eric] agents: a REPEAT silent quit compacts from a 40% floor; Haik's opus-5 quits at ~149K, under the 180K trigger, so the band fix alone missed his storms (ENG-354) --- backend/apps/agents/core/models.py | 2 + .../apps/agents/manager/run/empty_finish.py | 13 ++++++- backend/tests/test_empty_finish.py | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 7b7a74e3..a60d0d20 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -139,6 +139,8 @@ class AgentSession(BaseModel): pending_continuation_toolless: bool = False # Silent-quit nudges spent since the user's last real message; hard-capped so an agent that keeps ending empty can't loop. empty_finish_nudges: int = 0 + # Lifetime silent-quit count for the session; unlike the per-user-message nudge counter this never resets, so a REPEAT quit is distinguishable from a first (Haik's storm sessions logged 130+ quits each). + empty_finish_total: int = 0 # One transparent expired-token retry per user ask; the second failure earns the honest banner (ENG-294). auth_retry_used: bool = False # Tool-call count at the last nudge: a re-nudge is only earned by NEW tool work since then. diff --git a/backend/apps/agents/manager/run/empty_finish.py b/backend/apps/agents/manager/run/empty_finish.py index 4dc9de86..fc79cbf6 100644 --- a/backend/apps/agents/manager/run/empty_finish.py +++ b/backend/apps/agents/manager/run/empty_finish.py @@ -57,11 +57,20 @@ def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool: session.empty_finish_progress_mark = p_tool_calls # At high context the silent quit is usually the model choking on the prompt itself, so # re-sending the same bloat just burns a nudge; compact FIRST and retry distilled (ENG-354). + # Field data (Haik, 2026-08-19): opus-5 quits from ~149K, BELOW the 180K trigger, and his storm + # sessions logged 130+ quits each; a REPEAT quit therefore compacts from a much lower floor, + # because one failed nudge is proof the prompt itself is what the model is choking on. + import os as p_os from backend.apps.agents.manager.context_budget import compact_trigger_tokens, maybe_compact p_input = int(session.tokens.get("input", 0) or 0) - if p_input >= int(0.8 * compact_trigger_tokens(session)) and maybe_compact(session, force=True): + p_repeat = getattr(session, "empty_finish_total", 0) >= 1 + session.empty_finish_total = getattr(session, "empty_finish_total", 0) + 1 + p_floor = int((0.4 if p_repeat else 0.8) * compact_trigger_tokens(session)) + # Drill seam: the ENG-354 negative control runs the exp.14 behavior (no compact) on identical bits. + p_disabled = p_os.environ.get("OSW_DISABLE_EMPTY_FINISH_COMPACT") == "1" + if not p_disabled and p_input >= p_floor and maybe_compact(session, force=True): session.needs_fresh_session = True - logger.warning(f"Agent {session_id}: empty finish at {p_input} input tokens; compacted history before the nudge") + logger.warning(f"Agent {session_id}: empty finish at {p_input} input tokens (repeat={p_repeat}); compacted history before the nudge") session.empty_finish_nudges += 1 session.pending_continuation = True p_final = session.empty_finish_nudges >= NUDGE_HARD_CAP diff --git a/backend/tests/test_empty_finish.py b/backend/tests/test_empty_finish.py index 7d2bff1c..4339ddcd 100644 --- a/backend/tests/test_empty_finish.py +++ b/backend/tests/test_empty_finish.py @@ -163,3 +163,42 @@ def test_low_context_empty_finish_nudges_without_compacting(): assert maybe_nudge_empty_finish(s, "sid-low") is True assert s.compacted_through_msg_id is None assert getattr(s, "needs_fresh_session", False) is False + + +def test_repeat_quit_compacts_from_the_low_floor(): + """ENG-354 field data: opus-5 quits at ~149K, BELOW the 180K trigger; a repeat quit must compact anyway.""" + from backend.apps.agents.manager.run.empty_finish import maybe_nudge_empty_finish + s = p_session(("user", "long task"), + ("tool_call", {"tool": "Read", "input": {"file_path": "/a"}}), + ("tool_result", {"text": "x"}), + ("tool_call", {"tool": "Read", "input": {"file_path": "/b"}}), + ("tool_result", {"text": "y"}), + ("tool_call", {"tool": "Read", "input": {"file_path": "/c"}}), + ("tool_result", {"text": "z"})) + s.context_window = 1_000_000 + s.tokens["input"] = 100_000 # below the 144K first-quit floor, above the 72K repeat floor + assert maybe_nudge_empty_finish(s, "sid-r1") is True + assert s.compacted_through_msg_id is None, "first quit below the band must NOT compact" + # New user message resets the per-message counter but not the lifetime one; the loop consumed the pending continuation. + s.pending_continuation = False + s.empty_finish_nudges = 0 + s.empty_finish_progress_mark = 0 + assert maybe_nudge_empty_finish(s, "sid-r2") is True + assert s.compacted_through_msg_id is not None, "repeat quit must compact from the low floor" + assert s.needs_fresh_session is True + + +def test_drill_seam_disables_compaction(monkeypatch): + from backend.apps.agents.manager.run.empty_finish import maybe_nudge_empty_finish + monkeypatch.setenv("OSW_DISABLE_EMPTY_FINISH_COMPACT", "1") + s = p_session(("user", "t"), + ("tool_call", {"tool": "Read", "input": {}}), + ("tool_result", {"text": "x"}), + ("tool_call", {"tool": "Read", "input": {}}), + ("tool_result", {"text": "y"}), + ("tool_call", {"tool": "Read", "input": {}}), + ("tool_result", {"text": "z"})) + s.context_window = 1_000_000 + s.tokens["input"] = 190_000 + assert maybe_nudge_empty_finish(s, "sid-seam") is True + assert s.compacted_through_msg_id is None