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>
This commit is contained in:
yassin 2026-08-19 21:41:57 +00:00
parent ed84e82428
commit 8ef522a2a0
2 changed files with 50 additions and 12 deletions

View file

@ -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(

View file

@ -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()