From b6f8fbb5ef4e309319571c84c38c1ffd84e9e8ea Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 5 Aug 2026 20:10:54 -0700 Subject: [PATCH] [eric] agents: silent-quit re-nudges are earned by new tool work, capped at 3, stalls surface honestly (ENG-121 family) --- backend/apps/agents/core/models.py | 2 + backend/apps/agents/manager/Messaging.py | 1 + .../apps/agents/manager/run/empty_finish.py | 20 ++++++-- backend/tests/test_empty_finish.py | 49 ++++++++++++++----- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index e2415437..3bec810e 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -135,6 +135,8 @@ class AgentSession(BaseModel): pending_continuation_prompt: Optional[str] = None # Silent-quit nudges spent since the user's last real message; capped at 1 so an agent that keeps ending empty can't loop. empty_finish_nudges: int = 0 + # Tool-call count at the last nudge: a re-nudge is only earned by NEW tool work since then. + empty_finish_progress_mark: int = 0 # Sanitized server names model has explicitly activated this session; _build_mcp_servers intersects connected MCPs with this. Non-bypassable; dispatch-layer gate. active_mcps: list[str] = Field(default_factory=list) # Heuristic preamble tokens (preset + tool defs + MCP descs + composed prompt); subtracted from displayed input. diff --git a/backend/apps/agents/manager/Messaging.py b/backend/apps/agents/manager/Messaging.py index daf5ba96..b0d206ce 100644 --- a/backend/apps/agents/manager/Messaging.py +++ b/backend/apps/agents/manager/Messaging.py @@ -136,6 +136,7 @@ class Messaging(AgentManagerProtocol): # A real user message opens a fresh silent-quit budget; the cap only guards within one ask. if not hidden: session.empty_finish_nudges = 0 + session.empty_finish_progress_mark = 0 # Fire a background aux LLM call to generate a 3-6 word verb-phrase describing this turn ("Auditing the pull request", "Drafting your email"). The narrator pill swaps from its heuristic verb to this label as soon as it lands, usually ~500ms-1s into the turn, which is exactly when "Thinking…" starts feeling generic. Provider-agnostic via resolve_aux_model. Non-blocking; failure is silent and the heuristic stays. if not hidden and prompt: try: diff --git a/backend/apps/agents/manager/run/empty_finish.py b/backend/apps/agents/manager/run/empty_finish.py index 1389ae1c..908c2bb7 100644 --- a/backend/apps/agents/manager/run/empty_finish.py +++ b/backend/apps/agents/manager/run/empty_finish.py @@ -20,14 +20,23 @@ NUDGE_PROMPT = ( logger = logging.getLogger(__name__) +NUDGE_HARD_CAP = 3 + + @typechecked def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool: - """Arm one hidden continue nudge when the finished turn quit silently; the loop's existing - auto-continuation block dispatches it. Capped per user ask; never loops.""" - if getattr(session, "pending_continuation", False) or session.empty_finish_nudges >= 1: + """Arm a hidden continue nudge when the finished turn quit silently; the loop's existing + auto-continuation block dispatches it. A re-nudge must be EARNED by new tool work since the + last one (the model is visibly still working, just mute); a stalled continuation surfaces + honestly, so this can never ping-pong a model that has nothing left to do.""" + if getattr(session, "pending_continuation", False) or session.empty_finish_nudges >= NUDGE_HARD_CAP: return False if not turn_finished_empty(session): return False + p_tool_calls = p_count_tool_calls(session) + 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 session.empty_finish_nudges += 1 session.pending_continuation = True session.pending_continuation_prompt = NUDGE_PROMPT @@ -43,6 +52,11 @@ def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool: P_ANSWER_TOOL_MARKERS = ("openswarm-ui", "ShowUI", "AskUI", "AskUserQuestion") +@typechecked +def p_count_tool_calls(session: AgentSession) -> int: + return sum(1 for m in get_branch_messages(session) if getattr(m, "role", "") == "tool_call") + + def p_tool_name_of(msg: object) -> str: content = getattr(msg, "content", None) if isinstance(content, dict): diff --git a/backend/tests/test_empty_finish.py b/backend/tests/test_empty_finish.py index 7b5d60ca..3eb7c3b2 100644 --- a/backend/tests/test_empty_finish.py +++ b/backend/tests/test_empty_finish.py @@ -1,13 +1,19 @@ -"""The silent-quit seal: a turn that runs tools and ends with no visible answer gets one hidden -continue nudge, never a loop. Detector shapes pinned here; the loop wiring is pinned in -test_context_pressure_valve-style fashion against run_agent_loop.""" +"""The silent-quit seal: a turn that runs tools and ends with no visible answer gets a hidden +continue nudge. Re-nudges must be EARNED by new tool work (the model is working but mute); a +stalled continuation surfaces honestly, and a hard cap bounds the worst case. Detector shapes +pinned here; the loop wiring is pinned against run_agent_loop.""" import asyncio from backend.apps.agents.agent_manager import agent_manager import backend.apps.agents.agent_manager as agent_manager_module from backend.apps.agents.core.models import AgentSession, Message -from backend.apps.agents.manager.run.empty_finish import NUDGE_PROMPT, turn_finished_empty +from backend.apps.agents.manager.run.empty_finish import ( + NUDGE_HARD_CAP, + NUDGE_PROMPT, + maybe_nudge_empty_finish, + turn_finished_empty, +) def p_session(*msgs) -> AgentSession: @@ -66,7 +72,7 @@ def p_install_run_fakes(monkeypatch, run_turn_fake) -> None: monkeypatch.setattr(agent_manager_module, "save_session", lambda sid, data: None) -def test_loop_nudges_a_silent_quit_once(monkeypatch) -> None: +def test_loop_renudges_while_progressing_then_caps(monkeypatch) -> None: session = AgentSession(name="t", model="sonnet", dashboard_id="d") agent_manager.sessions[session.id] = session continues: list = [] @@ -74,7 +80,7 @@ def test_loop_nudges_a_silent_quit_once(monkeypatch) -> None: async def fake_run_turn(sess, session_id, prompt_content, options, options_kwargs, turn, thinking, stderr, resolved_model, api_type, global_settings, force_respawn=False): - # Every turn ends as a silent quit: tools ran, no answer text. + # Every turn ends as a silent quit: NEW tool work ran, no answer text. sess.messages.append(Message(role="tool_call", content={"tool": "Bash", "input": {}}, branch_id="main")) sess.messages.append(Message(role="tool_result", content={"text": "out"}, branch_id="main")) @@ -88,12 +94,33 @@ def test_loop_nudges_a_silent_quit_once(monkeypatch) -> None: await agent_manager.run_agent_loop(session.id, "audit everything") await asyncio.sleep(0) - asyncio.run(main()) - assert continues == [{"prompt": NUDGE_PROMPT, "hidden": True}] - assert session.empty_finish_nudges == 1 + # Each silent quit made fresh tool progress, so each earns a nudge, up to the hard cap. + for expected in range(1, NUDGE_HARD_CAP + 1): + continues.clear() + asyncio.run(main()) + assert continues == [{"prompt": NUDGE_PROMPT, "hidden": True}] + assert session.empty_finish_nudges == expected - # The nudged turn ALSO quits silently: the cap must hold, no second nudge, no loop. + # At the cap even a progressing silent quit surfaces honestly: no nudge, no loop. continues.clear() asyncio.run(main()) assert continues == [] - assert session.empty_finish_nudges == 1 + assert session.empty_finish_nudges == NUDGE_HARD_CAP + + +def test_stalled_continuation_is_not_renudged() -> None: + s = p_session(("user", "audit"), + ("tool_call", {"tool": "Bash", "input": {}}), + ("tool_result", {"text": "ok"})) + assert maybe_nudge_empty_finish(s, "sid") is True + assert s.empty_finish_nudges == 1 + # The continuation dispatched, added NOTHING, and quit silently again: no second nudge. + s.pending_continuation = False + assert maybe_nudge_empty_finish(s, "sid") is False + assert s.empty_finish_nudges == 1 + # New tool work arrives: the re-nudge is earned again. + s.messages.append(Message(role="tool_call", content={"tool": "Grep", "input": {}}, branch_id="main")) + s.messages.append(Message(role="tool_result", content={"text": "hit"}, branch_id="main")) + s.pending_continuation = False + assert maybe_nudge_empty_finish(s, "sid") is True + assert s.empty_finish_nudges == 2