diff --git a/backend/apps/agents/manager/run/empty_finish.py b/backend/apps/agents/manager/run/empty_finish.py index 95aa16c8..4dc9de86 100644 --- a/backend/apps/agents/manager/run/empty_finish.py +++ b/backend/apps/agents/manager/run/empty_finish.py @@ -55,6 +55,13 @@ def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool: if session.empty_finish_nudges >= 1 and p_tool_calls <= session.empty_finish_progress_mark: return False 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). + 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): + session.needs_fresh_session = True + logger.warning(f"Agent {session_id}: empty finish at {p_input} input tokens; compacted history before the nudge") session.empty_finish_nudges += 1 session.pending_continuation = True p_final = session.empty_finish_nudges >= NUDGE_HARD_CAP @@ -72,6 +79,8 @@ def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool: "kind": "empty_finish_nudge", "session_id": session_id, "model": session.model, + "input_tokens": p_input, + "compacted": bool(session.needs_fresh_session), "tool_calls": p_tool_calls, "nudge": session.empty_finish_nudges, "flight": p_fr.build_envelope( diff --git a/backend/tests/test_empty_finish.py b/backend/tests/test_empty_finish.py index df575934..7d2bff1c 100644 --- a/backend/tests/test_empty_finish.py +++ b/backend/tests/test_empty_finish.py @@ -134,3 +134,32 @@ def test_stalled_continuation_is_not_renudged() -> None: s.pending_continuation = False assert maybe_nudge_empty_finish(s, "sid") is True assert s.empty_finish_nudges == 2 + + +def test_high_context_empty_finish_compacts_before_nudge(): + """ENG-354: a silent quit at high context must compact + go fresh, not just re-send the bloat.""" + from backend.apps.agents.manager.run.empty_finish import maybe_nudge_empty_finish + s = p_session(("user", "long task"), + ("tool_call", {"tool": "Bash", "input": {"command": "ls"}}), + ("tool_result", {"text": "ok"}), + ("tool_call", {"tool": "Bash", "input": {"command": "pwd"}}), + ("tool_result", {"text": "/tmp"}), + ("tool_call", {"tool": "Bash", "input": {"command": "date"}}), + ("tool_result", {"text": "now"})) + s.context_window = 200_000 + s.tokens["input"] = 190_000 + assert maybe_nudge_empty_finish(s, "sid-high") is True + assert s.compacted_through_msg_id is not None + assert s.needs_fresh_session is True + + +def test_low_context_empty_finish_nudges_without_compacting(): + from backend.apps.agents.manager.run.empty_finish import maybe_nudge_empty_finish + s = p_session(("user", "small task"), + ("tool_call", {"tool": "Bash", "input": {"command": "ls"}}), + ("tool_result", {"text": "ok"})) + s.context_window = 1_000_000 + s.tokens["input"] = 30_000 + 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