From b621463e97ce248f2a185811e1ccbe97f8e8c21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20=C3=93=20Callaghan?= Date: Thu, 17 Sep 2026 19:03:49 -0700 Subject: [PATCH] fix(sourcehunt): synthesize before stall termination --- clearwing/sourcehunt/hunter.py | 16 ++++++++++++++-- tests/test_deep_agent_loop.py | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/clearwing/sourcehunt/hunter.py b/clearwing/sourcehunt/hunter.py index 4dfbeaa9..00544760 100644 --- a/clearwing/sourcehunt/hunter.py +++ b/clearwing/sourcehunt/hunter.py @@ -1647,13 +1647,25 @@ async def arun(self) -> HunterRunResult: if not synthesis_injected: near_budget = self.budget_usd > 0 and total_cost_usd >= self.budget_usd * 0.75 near_steps = step >= self.max_steps - 1 - if near_budget or near_steps: + near_stall = ( + self.max_steps_without_progress > 0 + and steps_since_progress >= self.max_steps_without_progress + ) + if near_budget or near_steps or near_stall: synthesis_injected = True synthesis_prompt_added = True + if near_stall and not (near_budget or near_steps): + synthesis_reason = ( + "Your investigation has made no durable progress for several " + "turns and will stop after this recovery turn unless you conclude " + "it now." + ) + else: + synthesis_reason = "You are approaching the end of your budget." messages.append( ChatMessage( "user", - "You are approaching the end of your budget. Synthesize your final " + f"{synthesis_reason} Synthesize your final " "findings now. For each lead you investigated, either record_finding " "if it is source-backed, or discard it only if the source affirmatively " "disproves it. Do not drop leads merely because you ran out of time " diff --git a/tests/test_deep_agent_loop.py b/tests/test_deep_agent_loop.py index 4be5b5ae..71063c87 100644 --- a/tests/test_deep_agent_loop.py +++ b/tests/test_deep_agent_loop.py @@ -997,6 +997,32 @@ async def test_hunter_stalls_when_no_progress(): assert llm.achat.call_count < 20 # stops well short of max_steps +@pytest.mark.asyncio +async def test_early_stall_delivers_final_synthesis_prompt_and_can_complete(): + hunter, llm = _make_hunter(agent_mode="deep", max_steps=500, budget_usd=0.0) + hunter.max_steps_without_progress = 3 + llm.achat.side_effect = [ + FakeResponse( + text="still investigating", + tool_calls_list=[_make_tool_call("think", {"notes": "first pass"})], + ), + FakeResponse( + text="still investigating", + tool_calls_list=[_make_tool_call("think", {"notes": "second pass"})], + ), + FakeResponse(text="Investigation complete; no finding was recorded."), + ] + + with patch("clearwing.sourcehunt.hunter.HunterTrajectoryLogger") as mock_traj: + mock_traj.for_hunter.return_value = MagicMock() + result = await hunter.arun() + + assert result.stop_reason == "completed" + assert llm.achat.call_count == 3 + recovery_messages = llm.achat.call_args.kwargs["messages"] + assert any("made no durable progress" in message.content for message in recovery_messages) + + @pytest.mark.asyncio async def test_late_stall_delivers_final_synthesis_prompt_before_stopping(): hunter, llm = _make_hunter(agent_mode="deep", max_steps=4, budget_usd=0.0)