Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion clearwing/sourcehunt/hunter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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)",
Expand Down
29 changes: 29 additions & 0 deletions tests/test_deep_agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Loading