From 8ef522a2a034239b8544ba3febec03883ad4346e Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 19 Aug 2026 21:41:57 +0000 Subject: [PATCH] fix(search): read AgentCore structuredContent results Web-search connector 1.1.0 and later return the machine-readable results in result.structuredContent and may leave the text block as prose, which the parser dropped. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/bedrock/search/transformation.py | 31 ++++++++++++------- .../test_agentcore_search_transformation.py | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/litellm/llms/bedrock/search/transformation.py b/litellm/llms/bedrock/search/transformation.py index 94b69912faa..b53236347de 100644 --- a/litellm/llms/bedrock/search/transformation.py +++ b/litellm/llms/bedrock/search/transformation.py @@ -104,6 +104,13 @@ def _to_search_result(item: Mapping[str, object]) -> SearchResult: ) +def _result_items(parsed: object) -> tuple[Mapping[str, object], ...]: + items: Final = parsed.get("results", ()) if isinstance(parsed, Mapping) else parsed + if not isinstance(items, Sequence) or isinstance(items, (str, bytes)): + return () + return tuple(item for item in items if isinstance(item, Mapping)) + + def _parse_result_items(raw_text: object) -> tuple[Mapping[str, object], ...]: """ Parse one MCP text block into the search result objects it carries. @@ -117,10 +124,7 @@ def _parse_result_items(raw_text: object) -> tuple[Mapping[str, object], ...]: parsed: Final = json.loads(raw_text) except json.JSONDecodeError: return () - items: Final = parsed.get("results", ()) if isinstance(parsed, dict) else parsed - if not isinstance(items, Sequence) or isinstance(items, (str, bytes)): - return () - return tuple(item for item in items if isinstance(item, dict)) + return _result_items(parsed) def _iter_sse_events(text: str) -> Iterator[Mapping[str, object]]: @@ -350,7 +354,9 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM): The gateway returns JSON-RPC (as plain JSON or a single-message SSE stream) whose result.content[] text blocks contain a JSON list of - {title, url, date/publishedDate, text} entries. + {title, url, date/publishedDate, text} entries. Web-search connector + 1.1.0 and later repeat that list in result.structuredContent, which is + the only machine-readable copy when the text block holds prose instead. """ response_json: Final = self._parse_mcp_body(raw_response) @@ -370,14 +376,15 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM): message=f"AgentCore web search tool error: {self._tool_error_message(response_json)}", ) - return SearchResponse( - results=[ # mutable-ok: SearchResponse.results is a pydantic list field - _to_search_result(item) - for block in self._text_blocks(response_json) - for item in _parse_result_items(block.get("text")) - ], - object="search", + text_items: Final = tuple( + item for block in self._text_blocks(response_json) for item in _parse_result_items(block.get("text")) ) + structured: Final = result.get("structuredContent") if isinstance(result, Mapping) else None + items: Final = text_items or _result_items(structured) + + results: Final = [_to_search_result(item) for item in items] # mutable-ok: pydantic list field + + return SearchResponse(results=results, object="search") def _tool_error_message(self, response_json: Mapping[str, object]) -> str: texts: Final = tuple( diff --git a/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py b/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py index 78808542c82..46f98356279 100644 --- a/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py +++ b/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py @@ -261,6 +261,37 @@ class TestAgentCoreSearch: with pytest.raises(Exception, match="AccessDeniedException"): config.transform_search_response(raw_response=mock_response, logging_obj=MagicMock()) + def test_transform_search_response_reads_structured_content(self): + """Connector 1.1.0+ puts the machine-readable results in structuredContent and may + leave the text block as prose, which must not come back as an empty result list.""" + config = AgentCoreSearchConfig() + mock_response = _make_mock_response( + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "Here is a prose summary of what I found."}], + "structuredContent": {"id": "824f89d0", "results": MCP_RESULTS}, + }, + } + ) + + response = config.transform_search_response(raw_response=mock_response, logging_obj=MagicMock()) + assert [result.title for result in response.results] == ["Test Result 1", "Test Result 2"] + assert response.results[0].url == "https://example.com/1" + assert response.results[0].snippet == "Snippet for result 1" + assert response.results[0].date == "2026-06-16" + + def test_transform_search_response_does_not_duplicate_structured_content(self): + """1.1.0+ repeats the same results in both places, so parsing both would double them.""" + config = AgentCoreSearchConfig() + body = _mcp_response_body() + body["result"]["structuredContent"] = {"results": MCP_RESULTS} + mock_response = _make_mock_response(body) + + response = config.transform_search_response(raw_response=mock_response, logging_obj=MagicMock()) + assert len(response.results) == 2 + def test_transform_search_response_parses_crlf_framed_sse(self): """SSE streams may be CRLF framed; events must still split into separate events.""" config = AgentCoreSearchConfig()