From 5464f068c32bd795c70062a8bd10bf1f1ae40cd4 Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Thu, 24 Sep 2026 01:10:37 -0700 Subject: [PATCH] Allow repeated automatic WebSearch across providers (#1871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Closes #1862. Automatic WebSearch rejected a model response with multiple searches and rejected a later search when the conversation included an earlier search result. These failures occurred in FCC's shared path regardless of the selected provider. ## How Project completed search history into provider-neutral quoted text before forwarding a new request upstream, while preserving the client-visible history. Execute multiple model-selected searches with bounded concurrency, honor `max_uses`, and return linked result or error blocks with the actual search count in one Anthropic response. Bump FCC to 6.2.58.

RetriggerConfidence Score: 5/5

The changed automatic search flow preserves valid upstream text responses when no search is selected and introduces no identified merge-blocking behavior. What we checked: - Validated that the zero-tool-call upstream path produced HTTP 200 with the same valid text both before and after the guard, and that contexts[0] was not reached because the guard returned at line 161. T-Rex - Concluded there is no security issue from this behavior, as it does not introduce unhandled failures, information exposure, or a security boundary bypass. T-Rex - Inspected the supporting artifacts to verify the guard behavior and response integrity: the Python contract validation script and two runtime logs. T-Rex
Summary - Enables multiple automatic web searches in a single model response, with bounded concurrency and `max_uses` enforcement. - Makes completed search history available to later requests by converting it into provider-neutral context. - Extends Anthropic SSE output to represent multiple linked tool-use and result blocks while retaining the existing single-search behavior. - An automatic-search request for which the upstream model selects no search continues to return the upstream text response successfully. This change is safe to merge.
Reviews (1) ยท Last reviewed commit: ["Support repeated automatic web searches ..."](https://github.com/alishahryar1/free-claude-code/commit/2267f0281d19216287fe3920ca313537cfb5f9e5) --- pyproject.toml | 2 +- .../application/web_tools/request.py | 113 +++++- .../application/web_tools/service.py | 167 ++++++--- .../core/anthropic/server_tool_sse.py | 50 ++- tests/api/test_web_server_tools.py | 351 +++++++++++++++++- uv.lock | 2 +- 6 files changed, 599 insertions(+), 86 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 823cd38e0..b56df9659 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.57" +version = "6.2.58" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" diff --git a/src/free_claude_code/application/web_tools/request.py b/src/free_claude_code/application/web_tools/request.py index 8f96fb8e4..1efee57cf 100644 --- a/src/free_claude_code/application/web_tools/request.py +++ b/src/free_claude_code/application/web_tools/request.py @@ -1,10 +1,17 @@ """Recognize the narrow Anthropic web-tool requests FCC can execute locally.""" +import json import re from dataclasses import dataclass from free_claude_code.application.errors import InvalidRequestError from free_claude_code.core.anthropic import MessagesRequest, Tool +from free_claude_code.core.anthropic.models import ( + ContentBlockServerToolUse, + ContentBlockText, + ContentBlockWebSearchToolResult, + Message, +) WEB_SEARCH_TYPE = "web_search_20250305" WEB_FETCH_TYPE = "web_fetch_20250910" @@ -41,6 +48,7 @@ class AutomaticWebSearchPlan: request: MessagesRequest domains: WebSearchDomainFilter + max_uses: int | None def forced_tool_turn_text(request: MessagesRequest) -> str: @@ -117,10 +125,6 @@ def plan_automatic_web_search( "Anthropic web_search is disabled (ENABLE_WEB_SERVER_TOOLS=false). " "Enable local web tools or remove web_search from the request." ) - if _has_server_tool_history(request): - raise InvalidRequestError( - "Automatic web_search does not accept prior Anthropic server-tool history." - ) if tool.name != "web_search": raise InvalidRequestError( f"Server tool type {WEB_SEARCH_TYPE!r} must use name 'web_search'." @@ -138,7 +142,7 @@ def plan_automatic_web_search( + ", ".join(unsupported) + "." ) - _validate_max_uses(options.get("max_uses")) + max_uses = _validate_max_uses(options.get("max_uses")) allowed = _parse_domains( options.get("allowed_domains"), field="web_search.allowed_domains" ) @@ -161,12 +165,16 @@ def plan_automatic_web_search( }, ) translated = request.model_copy( - update={"tools": [hidden_tool]}, + update={ + "tools": [hidden_tool], + "messages": _normalized_search_history(request.messages), + }, deep=True, ) return AutomaticWebSearchPlan( request=translated, domains=WebSearchDomainFilter(allowed=allowed, blocked=blocked), + max_uses=max_uses, ) @@ -190,21 +198,96 @@ def unsupported_server_tool_error( return None -def _has_server_tool_history(request: MessagesRequest) -> bool: - for message in request.messages: - if not isinstance(message.content, list): +_INCOMPLETE_HISTORY = ( + "Automatic web_search does not accept incomplete or unsupported prior " + "Anthropic server-tool history." +) + + +def _normalized_search_history(messages: list[Message]) -> list[Message]: + """Quote completed local search data for every upstream transport.""" + normalized: list[Message] = [] + for message in messages: + content = message.content + if not isinstance(content, list) or not any( + getattr(block, "type", None) in _SERVER_HISTORY_TYPES for block in content + ): + normalized.append(message) continue - for block in message.content: - if getattr(block, "type", None) in _SERVER_HISTORY_TYPES: - return True - return False + if message.role != "assistant": + raise InvalidRequestError(_INCOMPLETE_HISTORY) + + uses: dict[str, ContentBlockServerToolUse] = {} + results: dict[str, ContentBlockWebSearchToolResult] = {} + for block in content: + if isinstance(block, ContentBlockServerToolUse): + query = block.input.get("query") + if ( + block.name != "web_search" + or not block.id + or block.id in uses + or set(block.input) != {"query"} + or not isinstance(query, str) + or not query.strip() + ): + raise InvalidRequestError(_INCOMPLETE_HISTORY) + uses[block.id] = block + elif isinstance(block, ContentBlockWebSearchToolResult): + if not block.tool_use_id or block.tool_use_id in results: + raise InvalidRequestError(_INCOMPLETE_HISTORY) + results[block.tool_use_id] = block + elif getattr(block, "type", None) in _SERVER_HISTORY_TYPES: + raise InvalidRequestError(_INCOMPLETE_HISTORY) + if not uses or uses.keys() != results.keys(): + raise InvalidRequestError(_INCOMPLETE_HISTORY) + + projected = [] + for block in content: + if isinstance(block, ContentBlockServerToolUse): + projected.append(_search_history_text(block, results[block.id])) + elif not isinstance(block, ContentBlockWebSearchToolResult): + projected.append(block) + normalized.append(message.model_copy(update={"content": projected}, deep=True)) + return normalized + + +def _search_history_text( + use: ContentBlockServerToolUse, result: ContentBlockWebSearchToolResult +) -> ContentBlockText: + content = result.content + if isinstance(content, list): + hits: list[dict[str, str]] = [] + for hit in content: + if ( + not isinstance(hit, dict) + or hit.get("type") != "web_search_result" + or not isinstance(hit.get("title"), str) + or not isinstance(hit.get("url"), str) + ): + raise InvalidRequestError(_INCOMPLETE_HISTORY) + hits.append({"title": hit["title"], "url": hit["url"]}) + record: dict[str, object] = {"query": use.input["query"], "results": hits} + elif ( + isinstance(content, dict) + and content.get("type") == "web_search_tool_result_error" + and isinstance(content.get("error_code"), str) + ): + record = {"query": use.input["query"], "error_code": content["error_code"]} + else: + raise InvalidRequestError(_INCOMPLETE_HISTORY) + return ContentBlockText( + type="text", + text="[Earlier web search data]\n" + + json.dumps(record, ensure_ascii=False, separators=(",", ":")), + ) -def _validate_max_uses(value: object) -> None: +def _validate_max_uses(value: object) -> int | None: if value is None: - return + return None if isinstance(value, bool) or not isinstance(value, int) or value <= 0: raise InvalidRequestError("web_search.max_uses must be a positive integer.") + return value def _parse_domains(value: object, *, field: str) -> tuple[str, ...]: diff --git a/src/free_claude_code/application/web_tools/service.py b/src/free_claude_code/application/web_tools/service.py index 28a220556..2897757e0 100644 --- a/src/free_claude_code/application/web_tools/service.py +++ b/src/free_claude_code/application/web_tools/service.py @@ -1,5 +1,6 @@ """Application workflow for supported local Anthropic web-tool requests.""" +import asyncio import sys import uuid from collections.abc import AsyncIterator, Mapping @@ -20,12 +21,17 @@ from free_claude_code.core.anthropic.server_tool_sse import ( ServerToolResponseContext, server_tool_completion_frames, + server_tool_finish_frames, + server_tool_message_start_frames, + server_tool_result_frames, server_tool_start_frames, + server_tool_use_frames, web_fetch_result_block, web_search_result_block, web_tool_error_block, ) from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.json_types import JsonObject from free_claude_code.core.trace import close_stream_input, trace_event from free_claude_code.core.web_tools import WebSearchResult @@ -163,30 +169,26 @@ async def _stream_automatic_search( for chunk in chunks: yield chunk return - if len(tool_calls) != 1: - raise _protocol_failure( - "Upstream model returned multiple tool calls for automatic WebSearch.", - request_id=request_id, - ) - call = tool_calls[0] - if call.get("name") != HIDDEN_WEB_SEARCH_NAME: - raise _protocol_failure( - "Upstream model returned an unexpected tool for automatic WebSearch.", - request_id=request_id, - ) - arguments = call.get("input") - if not isinstance(arguments, dict) or set(arguments) != {"query"}: - raise _protocol_failure( - "Upstream model returned malformed arguments for automatic WebSearch.", - request_id=request_id, - ) - raw_query = arguments.get("query") - if not isinstance(raw_query, str) or not raw_query.strip(): - raise _protocol_failure( - "Upstream model returned an empty query for automatic WebSearch.", - request_id=request_id, - ) - query = raw_query.strip() + queries: list[str] = [] + for call in tool_calls: + if call.get("name") != HIDDEN_WEB_SEARCH_NAME: + raise _protocol_failure( + "Upstream model returned an unexpected tool for automatic WebSearch.", + request_id=request_id, + ) + arguments = call.get("input") + if not isinstance(arguments, dict) or set(arguments) != {"query"}: + raise _protocol_failure( + "Upstream model returned malformed arguments for automatic WebSearch.", + request_id=request_id, + ) + raw_query = arguments.get("query") + if not isinstance(raw_query, str) or not raw_query.strip(): + raise _protocol_failure( + "Upstream model returned an empty query for automatic WebSearch.", + request_id=request_id, + ) + queries.append(raw_query.strip()) provider_usage = _provider_usage(message) input_tokens = _integer_field(provider_usage, "input_tokens") if input_tokens is None: @@ -199,24 +201,62 @@ async def _stream_automatic_search( request_id=request_id, model=routed.resolved.original_model, ) - local_stream = self._stream_local_tool( - tool_name="web_search", - tool_input={"query": query}, - input_tokens=input_tokens, - response_model=routed.resolved.original_model, - provider_usage=provider_usage, - domains=plan.domains, - ) - try: - async for frame in local_stream: - yield frame - finally: - await close_stream_input( - local_stream, - owner="automatic_web_search", - source="api", - preserved_error=sys.exception(), + message_id = f"msg_{uuid.uuid4()}" + contexts = [ + ServerToolResponseContext( + message_id=message_id, + tool_id=f"srvtoolu_{uuid.uuid4().hex}", + model=routed.resolved.original_model, + tool_name="web_search", + tool_input={"query": query}, + input_tokens=input_tokens, + provider_usage=provider_usage, ) + for query in queries + ] + executed = min(len(contexts), plan.max_uses or len(contexts)) + for frame in server_tool_message_start_frames(contexts[0]): + yield frame + + limit = asyncio.Semaphore(4) + + async def search_one( + context: ServerToolResponseContext, + ) -> tuple[JsonObject, str]: + async with limit: + return await self._search_result(context, plan.domains) + + summaries: list[str] = [] + async with asyncio.TaskGroup() as group: + tasks = [ + group.create_task(search_one(context)) + for context in contexts[:executed] + ] + for index, context in enumerate(contexts): + for frame in server_tool_use_frames(context, index=index * 2): + yield frame + if index < executed: + result_block, summary = await tasks[index] + else: + result_block = web_tool_error_block( + context, error_code="max_uses_exceeded" + ) + summary = ( + f"Web search limit reached for: {context.tool_input['query']}" + ) + for frame in server_tool_result_frames( + result_block, index=index * 2 + 1 + ): + yield frame + summaries.append(summary) + + for frame in server_tool_finish_frames( + contexts[0], + summary="\n\n".join(summaries), + index=len(contexts) * 2, + request_count=executed, + ): + yield frame trace_event( stage="execution", event="free_claude_code.api.web_search.automatic_completed", @@ -247,15 +287,10 @@ async def _stream_local_tool( for frame in server_tool_start_frames(context): yield frame - try: - if tool_name == "web_search": - query = tool_input["query"] - results = await self._client.search(query) - if domains is not None: - results = _filter_results(results, domains) - result_block = web_search_result_block(context, results) - summary = _search_summary(query, results) - else: + if tool_name == "web_search": + result_block, summary = await self._search_result(context, domains) + else: + try: fetched = await self._client.fetch( tool_input["url"], egress=self._egress ) @@ -263,19 +298,37 @@ async def _stream_local_tool( context, fetched, retrieved_at=datetime.now(UTC).isoformat() ) summary = fetched.data - except Exception as error: - fetch_url = tool_input.get("url") if tool_name == "web_fetch" else None - _log_web_tool_failure(tool_name, error, fetch_url=fetch_url) - result_block = web_tool_error_block(context) - summary = _web_tool_client_error_summary( - tool_name, error, verbose=self._verbose_client_errors - ) + except Exception as error: + _log_web_tool_failure(tool_name, error, fetch_url=tool_input["url"]) + result_block = web_tool_error_block(context) + summary = _web_tool_client_error_summary( + tool_name, error, verbose=self._verbose_client_errors + ) for frame in server_tool_completion_frames( context, result_block, summary=summary ): yield frame + async def _search_result( + self, + context: ServerToolResponseContext, + domains: WebSearchDomainFilter | None, + ) -> tuple[JsonObject, str]: + query = context.tool_input["query"] + try: + results = await self._client.search(query) + if domains is not None: + results = _filter_results(results, domains) + return web_search_result_block(context, results), _search_summary( + query, results + ) + except Exception as error: + _log_web_tool_failure("web_search", error, fetch_url=None) + return web_tool_error_block(context), _web_tool_client_error_summary( + "web_search", error, verbose=self._verbose_client_errors + ) + def _search_summary(query: str, results: list[WebSearchResult]) -> str: if not results: diff --git a/src/free_claude_code/core/anthropic/server_tool_sse.py b/src/free_claude_code/core/anthropic/server_tool_sse.py index db9e7aa5a..7a705821f 100644 --- a/src/free_claude_code/core/anthropic/server_tool_sse.py +++ b/src/free_claude_code/core/anthropic/server_tool_sse.py @@ -28,6 +28,13 @@ class ServerToolResponseContext: def server_tool_start_frames(context: ServerToolResponseContext) -> Iterator[str]: + yield from server_tool_message_start_frames(context) + yield from server_tool_use_frames(context, index=0) + + +def server_tool_message_start_frames( + context: ServerToolResponseContext, +) -> Iterator[str]: yield format_sse_event( "message_start", { @@ -46,11 +53,16 @@ def server_tool_start_frames(context: ServerToolResponseContext) -> Iterator[str }, }, ) + + +def server_tool_use_frames( + context: ServerToolResponseContext, *, index: int +) -> Iterator[str]: yield format_sse_event( "content_block_start", { "type": "content_block_start", - "index": 0, + "index": index, "content_block": { "type": SERVER_TOOL_USE, "id": context.tool_id, @@ -60,7 +72,7 @@ def server_tool_start_frames(context: ServerToolResponseContext) -> Iterator[str }, ) yield format_sse_event( - "content_block_stop", {"type": "content_block_stop", "index": 0} + "content_block_stop", {"type": "content_block_stop", "index": index} ) @@ -104,14 +116,16 @@ def web_fetch_result_block( } -def web_tool_error_block(context: ServerToolResponseContext) -> JsonObject: +def web_tool_error_block( + context: ServerToolResponseContext, *, error_code: str = "unavailable" +) -> JsonObject: search = context.tool_name == "web_search" return { "type": WEB_SEARCH_TOOL_RESULT if search else WEB_FETCH_TOOL_RESULT, "tool_use_id": context.tool_id, "content": { "type": WEB_SEARCH_TOOL_RESULT_ERROR if search else WEB_FETCH_TOOL_ERROR, - "error_code": "unavailable", + "error_code": error_code, }, } @@ -122,18 +136,32 @@ def server_tool_completion_frames( *, summary: str, ) -> Iterator[str]: + yield from server_tool_result_frames(result_block, index=1) + yield from server_tool_finish_frames(context, summary=summary, index=2) + + +def server_tool_result_frames(result_block: JsonObject, *, index: int) -> Iterator[str]: yield format_sse_event( "content_block_start", - {"type": "content_block_start", "index": 1, "content_block": result_block}, + {"type": "content_block_start", "index": index, "content_block": result_block}, ) yield format_sse_event( - "content_block_stop", {"type": "content_block_stop", "index": 1} + "content_block_stop", {"type": "content_block_stop", "index": index} ) + + +def server_tool_finish_frames( + context: ServerToolResponseContext, + *, + summary: str, + index: int, + request_count: int = 1, +) -> Iterator[str]: yield format_sse_event( "content_block_start", { "type": "content_block_start", - "index": 2, + "index": index, "content_block": {"type": "text", "text": ""}, }, ) @@ -141,12 +169,12 @@ def server_tool_completion_frames( "content_block_delta", { "type": "content_block_delta", - "index": 2, + "index": index, "delta": {"type": "text_delta", "text": summary}, }, ) yield format_sse_event( - "content_block_stop", {"type": "content_block_stop", "index": 2} + "content_block_stop", {"type": "content_block_stop", "index": index} ) yield format_sse_event( "message_delta", @@ -158,6 +186,7 @@ def server_tool_completion_frames( summary, context.provider_usage, usage_key=f"{context.tool_name}_requests", + request_count=request_count, ), }, ) @@ -182,6 +211,7 @@ def _completion_usage( provider_usage: Mapping[str, object] | None, *, usage_key: str, + request_count: int, ) -> dict[str, object]: if provider_usage is None: usage: dict[str, object] = { @@ -192,7 +222,7 @@ def _completion_usage( usage = _integer_usage(provider_usage) usage.setdefault("input_tokens", input_tokens) usage.setdefault("output_tokens", max(1, len(summary) // 4)) - usage["server_tool_use"] = {usage_key: 1} + usage["server_tool_use"] = {usage_key: request_count} return usage diff --git a/tests/api/test_web_server_tools.py b/tests/api/test_web_server_tools.py index 8abb0310f..bb8dc44fb 100644 --- a/tests/api/test_web_server_tools.py +++ b/tests/api/test_web_server_tools.py @@ -34,12 +34,17 @@ from free_claude_code.config.provider_catalog import PROVIDER_CATALOG from free_claude_code.config.reasoning import ReasoningPreference from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.conversion import AnthropicToOpenAIConverter from free_claude_code.core.anthropic.models import ( ContentBlockServerToolUse, Message, MessagesRequest, Tool, ) +from free_claude_code.core.anthropic.native import ( + NativeMessagesOptions, + build_native_messages_request, +) from free_claude_code.core.anthropic.stream_contracts import ( assert_anthropic_stream_contract, parse_sse_text, @@ -48,6 +53,9 @@ from free_claude_code.core.anthropic.streaming import format_sse_event from free_claude_code.core.failures import ExecutionFailure, FailureKind from free_claude_code.core.openai_responses import OpenAIResponsesRequest +from free_claude_code.core.openai_responses.provider_input import ( + build_responses_provider_request, +) from free_claude_code.core.reasoning import ReasoningPolicy from free_claude_code.core.version import package_version from free_claude_code.core.web_tools import WebFetchResult, WebSearchResult @@ -268,11 +276,13 @@ def _provider_tool_events( name: str = HIDDEN_WEB_SEARCH_NAME, arguments: dict[str, object] | None = None, additional_calls: int = 0, + additional_inputs: list[dict[str, object]] | None = None, ) -> list[str]: calls = [(name, arguments if arguments is not None else {"query": "selected"})] calls.extend( (name, {"query": f"extra-{index}"}) for index in range(additional_calls) ) + calls.extend((name, item) for item in additional_inputs or []) events = [ format_sse_event( "message_start", @@ -763,15 +773,352 @@ async def fake_search(self, _query: str) -> list[WebSearchResult]: assert len(provider.requests) == 1 +@pytest.mark.asyncio +async def test_automatic_web_search_runs_multiple_calls_in_one_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = ScriptedSelectionProvider(_provider_tool_events(additional_calls=1)) + seen_queries: list[str] = [] + + async def fake_search(self, query: str) -> list[WebSearchResult]: + seen_queries.append(query) + return [ + WebSearchResult(title=f"{query} first", url="https://example.com/1"), + WebSearchResult(title=f"{query} second", url="https://example.com/2"), + ] + + monkeypatch.setattr( + "tests.web_tools_support.StubWebToolsClient.search", fake_search + ) + service = _automatic_search_service(provider) + + response = await service.create(_automatic_search_request(stream=False)) + + assert isinstance(response, JSONResponse) + body = _json_body(response) + assert [block["type"] for block in body["content"]] == [ + "server_tool_use", + "web_search_tool_result", + "server_tool_use", + "web_search_tool_result", + "text", + ] + first_use, first_result, second_use, second_result, _ = body["content"] + assert [first_use["input"]["query"], second_use["input"]["query"]] == [ + "selected", + "extra-0", + ] + assert first_use["id"] != second_use["id"] + assert first_result["tool_use_id"] == first_use["id"] + assert second_result["tool_use_id"] == second_use["id"] + assert len(first_result["content"]) == len(second_result["content"]) == 2 + assert set(seen_queries) == {"selected", "extra-0"} + assert body["usage"]["server_tool_use"] == {"web_search_requests": 2} + + +@pytest.mark.asyncio +async def test_automatic_web_search_streams_ordered_multiple_calls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = ScriptedSelectionProvider(_provider_tool_events(additional_calls=1)) + + async def fake_search(self, query: str) -> list[WebSearchResult]: + return [WebSearchResult(title=query, url=f"https://example.com/{query}")] + + monkeypatch.setattr( + "tests.web_tools_support.StubWebToolsClient.search", fake_search + ) + response = await _automatic_search_service(provider).create( + _automatic_search_request() + ) + + assert isinstance(response, StreamingResponse) + events = parse_sse_text(await _streaming_body_text(response)) + assert_anthropic_stream_contract(events) + starts = [event for event in events if event.event == "content_block_start"] + assert [event.data["index"] for event in starts] == list(range(5)) + assert [event.data["content_block"]["type"] for event in starts] == [ + "server_tool_use", + "web_search_tool_result", + "server_tool_use", + "web_search_tool_result", + "text", + ] + assert sum(event.event == "message_start" for event in events) == 1 + assert sum(event.event == "message_stop" for event in events) == 1 + assert next( + event.data["usage"]["server_tool_use"] + for event in events + if event.event == "message_delta" + ) == {"web_search_requests": 2} + + +@pytest.mark.asyncio +async def test_automatic_web_search_max_uses_limits_current_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = ScriptedSelectionProvider(_provider_tool_events(additional_calls=1)) + search = AsyncMock( + return_value=[WebSearchResult(title="found", url="https://example.com/")] + ) + monkeypatch.setattr("tests.web_tools_support.StubWebToolsClient.search", search) + request = _automatic_search_request( + stream=False, + tool=Tool(name="web_search", type="web_search_20250305", max_uses=1), + ) + + response = await _automatic_search_service(provider).create(request) + + assert isinstance(response, JSONResponse) + body = _json_body(response) + assert body["content"][1]["content"][0]["title"] == "found" + assert body["content"][3]["content"] == { + "type": "web_search_tool_result_error", + "error_code": "max_uses_exceeded", + } + search.assert_awaited_once_with("selected") + assert body["usage"]["server_tool_use"] == {"web_search_requests": 1} + + +@pytest.mark.asyncio +async def test_automatic_web_search_keeps_other_results_when_one_search_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = ScriptedSelectionProvider(_provider_tool_events(additional_calls=1)) + + async def fake_search(self, query: str) -> list[WebSearchResult]: + if query == "extra-0": + raise RuntimeError("search unavailable") + return [WebSearchResult(title="found", url="https://example.com/")] + + monkeypatch.setattr( + "tests.web_tools_support.StubWebToolsClient.search", fake_search + ) + response = await _automatic_search_service(provider).create( + _automatic_search_request(stream=False) + ) + + assert isinstance(response, JSONResponse) + body = _json_body(response) + assert body["content"][1]["content"][0]["title"] == "found" + assert body["content"][3]["content"] == { + "type": "web_search_tool_result_error", + "error_code": "unavailable", + } + assert body["usage"]["server_tool_use"] == {"web_search_requests": 2} + + +@pytest.mark.asyncio +async def test_automatic_web_search_validates_all_calls_before_search( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = ScriptedSelectionProvider( + _provider_tool_events(additional_inputs=[{"query": ""}]) + ) + search = AsyncMock() + monkeypatch.setattr("tests.web_tools_support.StubWebToolsClient.search", search) + + response = await _automatic_search_service(provider).create( + _automatic_search_request(stream=False) + ) + + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + search.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_automatic_web_search_bounds_parallel_local_searches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = ScriptedSelectionProvider(_provider_tool_events(additional_calls=4)) + four_started = asyncio.Event() + release = asyncio.Event() + active = 0 + peak = 0 + started: list[str] = [] + + async def fake_search(self, query: str) -> list[WebSearchResult]: + nonlocal active, peak + active += 1 + peak = max(peak, active) + started.append(query) + if active == 4: + four_started.set() + try: + await release.wait() + finally: + active -= 1 + return [] + + monkeypatch.setattr( + "tests.web_tools_support.StubWebToolsClient.search", fake_search + ) + pending = asyncio.create_task( + _automatic_search_service(provider).create( + _automatic_search_request(stream=False) + ) + ) + try: + await asyncio.wait_for(four_started.wait(), 1) + assert len(started) == 4 + assert peak == 4 + release.set() + response = await asyncio.wait_for(pending, 1) + assert isinstance(response, JSONResponse) + assert _json_body(response)["usage"]["server_tool_use"] == { + "web_search_requests": 5 + } + assert len(started) == 5 + finally: + release.set() + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_automatic_web_search_cancellation_stops_parallel_searches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = ScriptedSelectionProvider(_provider_tool_events(additional_calls=1)) + both_started = asyncio.Event() + started: set[str] = set() + cancelled: set[str] = set() + + async def fake_search(self, query: str) -> list[WebSearchResult]: + started.add(query) + if len(started) == 2: + both_started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.add(query) + return [] + + monkeypatch.setattr( + "tests.web_tools_support.StubWebToolsClient.search", fake_search + ) + response = await _automatic_search_service(provider).create( + _automatic_search_request() + ) + assert isinstance(response, StreamingResponse) + drain = asyncio.create_task(_streaming_body_text(response)) + try: + await asyncio.wait_for(both_started.wait(), 1) + drain.cancel() + with pytest.raises(asyncio.CancelledError): + await drain + assert cancelled == {"selected", "extra-0"} + assert provider.close_count == 1 + finally: + drain.cancel() + await asyncio.gather(drain, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_automatic_web_search_can_search_again_after_completed_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = ScriptedSelectionProvider( + _provider_tool_events(arguments={"query": "first"}) + ) + + async def fake_search(self, query: str) -> list[WebSearchResult]: + return [WebSearchResult(title=query, url=f"https://example.com/{query}")] + + monkeypatch.setattr( + "tests.web_tools_support.StubWebToolsClient.search", fake_search + ) + service = _automatic_search_service(provider) + first = await service.create(_automatic_search_request(stream=False)) + assert isinstance(first, JSONResponse) + first_content = _json_body(first)["content"] + + provider.events = _provider_tool_events(arguments={"query": "second"}) + second_request = _automatic_search_request(stream=False) + second_request.messages.extend( + [ + Message.model_validate({"role": "assistant", "content": first_content}), + Message(role="user", content="Search for a second thing"), + ] + ) + second = await service.create(second_request) + + assert isinstance(second, JSONResponse) + assert second.status_code == 200 + assert _json_body(second)["content"][1]["content"][0]["title"] == "second" + assert len(provider.requests) == 2 + assert all( + getattr(block, "type", None) + not in {"server_tool_use", "web_search_tool_result"} + for block in provider.requests[1].messages[1].content + ) + assert "https://example.com/first" in str(provider.requests[1].messages[1].content) + assert getattr(second_request.messages[1].content[0], "type", None) == ( + "server_tool_use" + ) + + +def test_automatic_web_search_history_projects_for_each_provider_transport() -> None: + request = _automatic_search_request() + request.messages.extend( + [ + Message.model_validate( + { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_prior", + "name": "web_search", + "input": {"query": "prior"}, + }, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_prior", + "content": [ + { + "type": "web_search_result", + "title": "Prior hit", + "url": "https://example.com/prior", + "encrypted_content": "opaque", + } + ], + }, + ], + } + ), + Message(role="user", content="Search again"), + ] + ) + + plan = plan_automatic_web_search(request, web_tools_enabled=True) + + assert plan is not None + chat = AnthropicToOpenAIConverter.convert_messages(plan.request.messages) + responses = build_responses_provider_request( + plan.request, reasoning=ReasoningPolicy.off() + ) + native = build_native_messages_request( + plan.request, + options=NativeMessagesOptions(model=plan.request.model, max_tokens=100), + ).body + for wire in (chat, responses, native): + serialized = json.dumps(wire) + assert "https://example.com/prior" in serialized + assert '"opaque"' not in serialized + assert "server_tool_use" not in serialized + assert getattr(request.messages[1].content[0], "type", None) == ("server_tool_use") + + @pytest.mark.asyncio @pytest.mark.parametrize( "events", [ _provider_tool_events(arguments={"query": ""}), _provider_tool_events(name="unexpected_tool"), - _provider_tool_events(additional_calls=1), ], - ids=["blank-query", "wrong-tool", "multiple-tools"], + ids=["blank-query", "wrong-tool"], ) async def test_automatic_web_search_rejects_malformed_provider_selection( events: list[str], diff --git a/uv.lock b/uv.lock index 4cecec007..4b1fb145b 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.57" +version = "6.2.58" source = { editable = "." } dependencies = [ { name = "aiohttp" },