diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py index 8ad06eea..aabaad40 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -132,12 +132,28 @@ def _exa_content(api_key: str, query: str, search_type: str, num_results: int) - return "\n\n".join(blocks) -def _exa_page_text(api_key: str, urls: list[str]) -> str: +def _normalize_url(url: str) -> str: + return url.strip().rstrip("/").lower() + + +def _exa_page_text(api_key: str, urls: list[str]) -> tuple[str, set[str]]: + """Fetch page text and report which of the requested URLs Exa returned.""" body = _exa_post(api_key, "https://api.exa.ai/contents", {"urls": urls, "text": True}) - blocks = _exa_blocks(body.get("results") or [], _exa_page_block) + blocks: list[str] = [] + fetched: set[str] = set() + results: list[Any] = body.get("results") or [] + for result in results: + if not isinstance(result, dict): + continue + page = cast("dict[str, Any]", result) + block = _exa_page_block(page) + if not block: + continue + blocks.append(block) + fetched.add(_normalize_url(str(page.get("url") or page.get("id") or ""))) if not blocks: raise ValueError("Exa returned no page contents") - return "\n\n".join(blocks) + return "\n\n".join(blocks), fetched def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs its own return @@ -175,11 +191,11 @@ def _not_configured_error(missing: str) -> dict[str, Any]: } -def _guarded_call( # noqa: PLR0911 - each error class needs its own sanitized return +def _guarded_call[T]( # noqa: PLR0911 - each error class needs its own sanitized return tool: str, rejected_hint: str, - fetch: Callable[[], str], -) -> str | dict[str, Any]: + fetch: Callable[[], T], +) -> T | dict[str, Any]: """Run a provider call and translate any failure into a sanitized error dict.""" try: return fetch() @@ -275,12 +291,24 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]: ) if isinstance(outcome, dict): return outcome - return { + content, fetched = outcome + missing = [url for url in cleaned if _normalize_url(url) not in fetched] + result: dict[str, Any] = { "success": True, - "urls": cleaned, + "urls": [url for url in cleaned if url not in missing], "provider": "exa", - "content": outcome, + "content": content, } + if missing: + logger.warning( + "web_get_contents returned %d of %d pages", len(cleaned) - len(missing), len(cleaned) + ) + result["failed_urls"] = missing + result["warning"] = ( + f"Exa returned no content for {len(missing)} of {len(cleaned)} requested URLs. " + "Those pages are missing from the content below" + ) + return result @function_tool(timeout=330) @@ -367,6 +395,11 @@ async def web_get_contents(ctx: RunContextWrapper, urls: list[str]) -> str: pins the provider to Perplexity, it returns an error and you should use ``web_search`` instead. + Some pages block extraction. When a page returns no content, the + result lists it under ``failed_urls`` and the ``content`` field holds + only the pages that came back. Check ``failed_urls`` before you + conclude that a page had nothing useful. + Args: urls: The page URLs to fetch, at most 10 per call. Use complete, public URLs (for example the ones returned by ``web_search``). diff --git a/tests/test_web_search.py b/tests/test_web_search.py index f9bd6fc3..49f4c418 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -226,12 +226,13 @@ def test_exa_page_text_requests_full_text_and_renders_pages( monkeypatch.setattr(requests, "post", fake_post) - content = tool._exa_page_text("ek", ["https://nvd.example/cve"]) + content, fetched = tool._exa_page_text("ek", ["https://nvd.example/cve"]) assert captured["url"] == "https://api.exa.ai/contents" assert captured["headers"]["x-api-key"] == "ek" assert captured["json"] == {"urls": ["https://nvd.example/cve"], "text": True} assert content == "### NVD entry\nhttps://nvd.example/cve\n\nFull advisory body." + assert fetched == {"https://nvd.example/cve"} def test_exa_page_text_truncates_a_long_page(monkeypatch: pytest.MonkeyPatch) -> None: @@ -243,7 +244,7 @@ def test_exa_page_text_truncates_a_long_page(monkeypatch: pytest.MonkeyPatch) -> {"results": [{"url": "https://ex.example", "text": body}]} ), ) - content = tool._exa_page_text("ek", ["https://ex.example"]) + content, _fetched = tool._exa_page_text("ek", ["https://ex.example"]) assert "truncated at" in content assert content.count("A") == tool._EXA_PAGE_MAX_CHARS @@ -306,7 +307,7 @@ def test_do_get_contents_returns_page_text(monkeypatch: pytest.MonkeyPatch) -> N integrations = IntegrationSettings(EXA_API_KEY="ek") monkeypatch.setattr(tool, "load_settings", _Settings) - monkeypatch.setattr(tool, "_exa_page_text", lambda *_a: "page") + monkeypatch.setattr(tool, "_exa_page_text", lambda *_a: ("page", {"https://ex.example"})) result = tool._do_get_contents([" https://ex.example "]) @@ -318,6 +319,57 @@ def test_do_get_contents_returns_page_text(monkeypatch: pytest.MonkeyPatch) -> N } +def test_do_get_contents_reports_urls_exa_did_not_return( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _Settings: + integrations = IntegrationSettings(EXA_API_KEY="ek") + + monkeypatch.setattr(tool, "load_settings", _Settings) + monkeypatch.setattr( + requests, + "post", + lambda *_a, **_kw: _FakeResponse( + {"results": [{"url": "https://ok.example/", "text": "Body."}]} + ), + ) + + result = tool._do_get_contents(["https://ok.example", "https://blocked.example"]) + + assert result["success"] is True + assert result["urls"] == ["https://ok.example"] + assert result["failed_urls"] == ["https://blocked.example"] + assert "1 of 2" in result["warning"] + assert "blocked.example" not in result["content"] + + +def test_do_get_contents_omits_the_warning_when_every_page_returns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _Settings: + integrations = IntegrationSettings(EXA_API_KEY="ek") + + monkeypatch.setattr(tool, "load_settings", _Settings) + monkeypatch.setattr( + requests, + "post", + lambda *_a, **_kw: _FakeResponse( + { + "results": [ + {"url": "https://a.example", "text": "A."}, + {"url": "https://b.example", "text": "B."}, + ] + } + ), + ) + + result = tool._do_get_contents(["https://a.example", "https://b.example"]) + + assert result["urls"] == ["https://a.example", "https://b.example"] + assert "failed_urls" not in result + assert "warning" not in result + + def test_do_get_contents_sanitizes_a_network_error(monkeypatch: pytest.MonkeyPatch) -> None: class _Settings: integrations = IntegrationSettings(EXA_API_KEY="ek")