diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index 072836492..2736312a3 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -3939,6 +3939,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac empty_search_intents = {} successful_search_intents = [] web_search_attempts = 0 + breadth_recovery_attempted = False empty_web_search_attempts = 0 successful_web_searches = 0 successful_web_retrievals = 0 @@ -4276,8 +4277,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac if ( broad_current_web_request(direct_user_text) and successful_web_searches == 1 + and web_search_attempts < 2 + and not breadth_recovery_attempted + and not search_completion_attempted and round_number < round_limit ): + breadth_recovery_attempted = True force_web_search_next_round = True replace_streamed_draft_on_finish = True history.pop() diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py index 56fcf5ce1..3ae27b6a6 100644 --- a/tests/test_clean_agent_preview.py +++ b/tests/test_clean_agent_preview.py @@ -1188,7 +1188,8 @@ async def test_stream_bounds_research_to_two_searches_fetch_then_synthesis(monke @pytest.mark.asyncio @pytest.mark.parametrize('embedded_article', [False, True]) -async def test_stream_retries_an_obviously_truncated_broad_web_answer(monkeypatch, embedded_article): +@pytest.mark.parametrize('empty_second_search', [False, True]) +async def test_stream_retries_an_obviously_truncated_broad_web_answer(monkeypatch, embedded_article, empty_second_search): """Broad current research expands, retrieves evidence, then synthesizes.""" import src.clean_agent_preview as module @@ -1227,7 +1228,7 @@ async def test_stream_retries_an_obviously_truncated_broad_web_answer(monkeypatc )}}]}, ] article = packets[-1]['choices'][0]['delta']['content'] - if embedded_article: + if embedded_article or empty_second_search: packets.pop(3) packets = iter(packets) requests = [] @@ -1249,7 +1250,14 @@ async def test_stream_retries_an_obviously_truncated_broad_web_answer(monkeypatc requests.append(kwargs['json']) return Response(next(packets)) + search_calls = 0 + async def execute(block, **kwargs): + nonlocal search_calls + if block.tool_type == 'web_search': + search_calls += 1 + if empty_second_search and search_calls == 2: + return block.tool_type, {'output': 'No matching results', 'exit_code': 0, 'evidence_status': 'empty'} return block.tool_type, { 'output': '[1] AI News\n https://example.org/ai-news' + ( '\n[CONTENT 1] From: https://example.org/ai-news\nTitle: Report\n-----\n' @@ -1274,11 +1282,11 @@ async def test_stream_retries_an_obviously_truncated_broad_web_answer(monkeypatc )] events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk] - assert len(requests) == (4 if embedded_article else 5) + assert len(requests) == (4 if embedded_article or empty_second_search else 5) assert requests[2]['tool_choice'] == { 'type': 'function', 'function': {'name': 'web_search'}, } - if not embedded_article: + if not embedded_article and not empty_second_search: assert requests[3]['tool_choice'] == { 'type': 'function', 'function': {'name': 'web_fetch'}, } @@ -1289,6 +1297,7 @@ async def test_stream_retries_an_obviously_truncated_broad_web_answer(monkeypatc and event.get('reason') == 'insufficient_research_breadth' for event in events ) + assert sum(event.get('reason') == 'insufficient_research_breadth' for event in events) == 1 assert any( event.get('type') == 'final_response' and 'fuller evidence-based briefing' in event.get('content', '')