From 3de3c6f30db13dd595b98864233d111711d8eddb Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Fri, 4 Sep 2026 15:48:11 +0000 Subject: [PATCH] feat(web_search): use Exa /search with highlights and configurable search type --- docs/advanced/configuration.mdx | 10 ++++- strix/config/settings.py | 11 ++++++ strix/tools/web_search/tool.py | 61 ++++++++++++++++++++----------- tests/test_web_search.py | 65 ++++++++++++++++++++++++--------- 4 files changed, 108 insertions(+), 39 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 9152c37b..ab067bf4 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -81,13 +81,21 @@ affecting the agents that do the actual testing. - API key for Exa. Enables real-time web search through the Exa `/answer` endpoint. This is the preferred web search provider. + API key for Exa. Enables real-time web search through the Exa `/search` endpoint. This is the preferred web search provider. Web search provider: `auto`, `perplexity`, or `exa`. With `auto`, Strix uses Exa when `EXA_API_KEY` is set, and Perplexity otherwise. Set an explicit provider to pin one when you configure both keys. + + Exa search mode: `auto`, `fast`, `instant`, `deep-lite`, `deep`, or `deep-reasoning`. Lower modes return results faster. Higher modes plan across more steps and take more time. This setting applies only to the Exa provider. + + + + Number of Exa results to return, from `1` to `100`. Each result includes short query-relevant highlights. This setting applies only to the Exa provider. + + Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://`), and Postman environments (`postman://?env=`) to resolve collection variables. Not needed when passing a local collection export file. diff --git a/strix/config/settings.py b/strix/config/settings.py index e0aaa409..9309ac39 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -121,6 +121,7 @@ class TelemetrySettings(BaseSettings): WebSearchProvider = Literal["auto", "perplexity", "exa"] +ExaSearchType = Literal["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"] class IntegrationSettings(BaseSettings): @@ -140,6 +141,16 @@ class IntegrationSettings(BaseSettings): default="auto", alias="STRIX_WEB_SEARCH_PROVIDER", ) + exa_search_type: ExaSearchType = Field( + default="auto", + alias="STRIX_EXA_SEARCH_TYPE", + ) + exa_num_results: int = Field( + default=5, + ge=1, + le=100, + alias="STRIX_EXA_NUM_RESULTS", + ) postman_api_key: str | None = Field( default=None, alias="POSTMAN_API_KEY", diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py index 06118cbb..86878c78 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -56,33 +56,46 @@ def _perplexity_content(api_key: str, query: str) -> str: return str(response.json()["choices"][0]["message"]["content"]) -def _exa_content(api_key: str, query: str) -> str: - url = "https://api.exa.ai/answer" +_EXA_HIGHLIGHT_MAX_CHARS = 750 + + +def _exa_result_block(result: dict[str, Any]) -> str | None: + result_url = str(result.get("url") or result.get("id") or "") + if not result_url: + return None + title = str(result.get("title") or result_url) + parts = [f"### {title}\n{result_url}"] + highlights = result.get("highlights") or [] + if isinstance(highlights, list): + excerpts = [str(item).strip() for item in highlights if str(item).strip()] + if excerpts: + parts.append("\n".join(f"> {excerpt}" for excerpt in excerpts)) + return "\n".join(parts) + + +def _exa_content(api_key: str, query: str, search_type: str, num_results: int) -> str: + url = "https://api.exa.ai/search" headers = {"x-api-key": api_key, "Content-Type": "application/json"} payload = { "query": f"{_SYSTEM_PROMPT}\n\n{query}", - "text": True, + "type": search_type, + "numResults": num_results, + "contents": {"highlights": {"maxCharacters": _EXA_HIGHLIGHT_MAX_CHARS}}, } with requests.post(url, headers=headers, json=payload, timeout=300) as response: response.raise_for_status() body: dict[str, Any] = response.json() - answer = str(body.get("answer") or "").strip() - if not answer: - raise ValueError("Exa response has no answer") - citations: list[Any] = body.get("citations") or [] - lines: list[str] = [] - for citation in citations: - if not isinstance(citation, dict): + results: list[Any] = body.get("results") or [] + blocks: list[str] = [] + for result in results: + if not isinstance(result, dict): continue - entry = cast("dict[str, Any]", citation) - citation_url = str(entry.get("url") or entry.get("id") or "") - if not citation_url: - continue - title = str(entry.get("title") or citation_url) - lines.append(f"- {title}: {citation_url}") - if lines: - return answer + "\n\nSources:\n" + "\n".join(lines) - return answer + block = _exa_result_block(cast("dict[str, Any]", result)) + if block: + blocks.append(block) + if not blocks: + raise ValueError("Exa response has no results") + return "\n\n".join(blocks) def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs its own return @@ -124,7 +137,8 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas if not query or not query.strip(): return {"success": False, "error": "Query cannot be empty"} - resolved = _resolve_provider(load_settings().integrations) + integrations = load_settings().integrations + resolved = _resolve_provider(integrations) if isinstance(resolved, dict): return resolved provider, api_key = resolved @@ -132,7 +146,12 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas try: if provider == "exa": - content = _exa_content(api_key, query) + content = _exa_content( + api_key, + query, + integrations.exa_search_type, + integrations.exa_num_results, + ) else: content = _perplexity_content(api_key, query) except requests.exceptions.Timeout: diff --git a/tests/test_web_search.py b/tests/test_web_search.py index fa1c6add..ffaae6b5 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -80,7 +80,7 @@ def test_no_keys_names_both_providers() -> None: assert "EXA_API_KEY or PERPLEXITY_API_KEY" in resolved["error"] -def test_exa_content_appends_citation_sources(monkeypatch: pytest.MonkeyPatch) -> None: +def test_exa_content_renders_results_with_highlights(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, Any] = {} def fake_post(url: str, **kwargs: Any) -> _FakeResponse: @@ -89,9 +89,12 @@ def test_exa_content_appends_citation_sources(monkeypatch: pytest.MonkeyPatch) - captured["json"] = kwargs["json"] return _FakeResponse( { - "answer": "CVE-2024-0001 affects it.", - "citations": [ - {"url": "https://nvd.example/cve", "title": "NVD entry"}, + "results": [ + { + "url": "https://nvd.example/cve", + "title": "NVD entry", + "highlights": ["CVE-2024-0001 affects it.", " "], + }, {"id": "https://blog.example/post"}, "not-a-dict", {"title": "no url"}, @@ -101,39 +104,43 @@ def test_exa_content_appends_citation_sources(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr(requests, "post", fake_post) - content = tool._exa_content("ek", "OpenSSH 7.4 RCE?") + content = tool._exa_content("ek", "OpenSSH 7.4 RCE?", "auto", 5) - assert captured["url"] == "https://api.exa.ai/answer" + assert captured["url"] == "https://api.exa.ai/search" assert captured["headers"]["x-api-key"] == "ek" assert "OpenSSH 7.4 RCE?" in captured["json"]["query"] + assert captured["json"]["type"] == "auto" + assert captured["json"]["numResults"] == 5 + assert captured["json"]["contents"]["highlights"]["maxCharacters"] > 0 assert content == ( - "CVE-2024-0001 affects it.\n\nSources:\n" - "- NVD entry: https://nvd.example/cve\n" - "- https://blog.example/post: https://blog.example/post" + "### NVD entry\nhttps://nvd.example/cve\n> CVE-2024-0001 affects it.\n\n" + "### https://blog.example/post\nhttps://blog.example/post" ) -def test_exa_content_without_citations_returns_the_answer( +def test_exa_content_renders_a_result_without_highlights( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( requests, "post", - lambda *_a, **_kw: _FakeResponse({"answer": " Nothing found. "}), + lambda *_a, **_kw: _FakeResponse( + {"results": [{"url": "https://ex.example", "title": "Ex"}]} + ), ) - assert tool._exa_content("ek", "q") == "Nothing found." + assert tool._exa_content("ek", "q", "auto", 5) == "### Ex\nhttps://ex.example" -@pytest.mark.parametrize("body", [{}, {"answer": None}, {"answer": " "}]) -def test_exa_content_rejects_an_empty_answer( +@pytest.mark.parametrize("body", [{}, {"results": None}, {"results": []}, {"results": ["x"]}]) +def test_exa_content_rejects_empty_results( monkeypatch: pytest.MonkeyPatch, body: dict[str, Any] ) -> None: monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body)) - with pytest.raises(ValueError, match="no answer"): - tool._exa_content("ek", "q") + with pytest.raises(ValueError, match="no results"): + tool._exa_content("ek", "q", "auto", 5) -def test_do_search_reports_an_empty_exa_answer_as_unexpected( +def test_do_search_reports_empty_exa_results_as_unexpected( monkeypatch: pytest.MonkeyPatch, ) -> None: class _Settings: @@ -167,6 +174,30 @@ def test_environment_validation_follows_provider_rules( assert _missing_web_search_vars(integrations) == expected +def test_exa_search_type_and_num_results_are_configurable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class _Settings: + integrations = IntegrationSettings( + EXA_API_KEY="ek", + STRIX_EXA_SEARCH_TYPE="deep-reasoning", + STRIX_EXA_NUM_RESULTS=3, + ) + + def fake_post(_url: str, **kwargs: Any) -> _FakeResponse: + captured["json"] = kwargs["json"] + return _FakeResponse({"results": [{"url": "https://ex.example", "title": "Ex"}]}) + + monkeypatch.setattr(tool, "load_settings", _Settings) + monkeypatch.setattr(requests, "post", fake_post) + + assert tool._do_search("q")["success"] is True + assert captured["json"]["type"] == "deep-reasoning" + assert captured["json"]["numResults"] == 3 + + def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch) -> None: class _Settings: integrations = IntegrationSettings(EXA_API_KEY="ek")