fix(web_search): report URLs Exa did not return from web_get_contents

This commit is contained in:
Ahmed Allam 2026-09-04 17:03:09 +00:00
parent 34d53db041
commit b1c15ec524
2 changed files with 97 additions and 12 deletions

View file

@ -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``).

View file

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