diff --git a/clearwing/sourcehunt/hunter.py b/clearwing/sourcehunt/hunter.py index ef58527d..4dfbeaa9 100644 --- a/clearwing/sourcehunt/hunter.py +++ b/clearwing/sourcehunt/hunter.py @@ -1627,6 +1627,7 @@ async def arun(self) -> HunterRunResult: prev_step_had_skip = False while True: step += 1 + synthesis_prompt_added = False progress_sig = ( len(self.ctx.findings), len(self.ctx.potentials), @@ -1648,6 +1649,7 @@ async def arun(self) -> HunterRunResult: near_steps = step >= self.max_steps - 1 if near_budget or near_steps: synthesis_injected = True + synthesis_prompt_added = True messages.append( ChatMessage( "user", @@ -1663,6 +1665,7 @@ async def arun(self) -> HunterRunResult: ) final_synthesis_turn = step == self.max_steps if final_synthesis_turn: + synthesis_prompt_added = True messages.append( ChatMessage( "user", @@ -1671,7 +1674,17 @@ async def arun(self) -> HunterRunResult: "recorded. If no finding was recorded, state that plainly.", ) ) - stop_reason = self._should_stop(step, total_cost_usd, steps_since_progress) + # A synthesis prompt must reach the model before the stall guard + # can terminate the hunt. Otherwise a late stall can enqueue the + # prompt and immediately exit without giving the model the final + # turn that was intended to conclude the investigation cleanly. + # Budget and max-step limits still win because only the progress + # counter is suppressed for this one turn. + stop_reason = self._should_stop( + step, + total_cost_usd, + 0 if synthesis_prompt_added else steps_since_progress, + ) if stop_reason: logger.warning( "Hunter stopped for %s: %s (step=%d, cost=$%.4f, findings=%d)", diff --git a/tests/test_deep_agent_loop.py b/tests/test_deep_agent_loop.py index 8def6586..4be5b5ae 100644 --- a/tests/test_deep_agent_loop.py +++ b/tests/test_deep_agent_loop.py @@ -995,3 +995,32 @@ async def test_hunter_stalls_when_no_progress(): assert result.stop_reason == "stalled" assert llm.achat.call_count < 20 # stops well short of max_steps + + +@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) + hunter.max_steps_without_progress = 3 + counter = itertools.count() + llm.achat.side_effect = [ + FakeResponse( + text="still investigating", + tool_calls_list=[_make_tool_call("think", {"notes": f"step {next(counter)}"})], + ), + FakeResponse( + text="still investigating", + tool_calls_list=[_make_tool_call("think", {"notes": f"step {next(counter)}"})], + ), + 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 + final_messages = llm.achat.call_args.kwargs["messages"] + assert any( + "approaching the end of your budget" in message.content for message in final_messages + )