feat(web_search): use Exa /search with highlights and configurable search type

This commit is contained in:
Ahmed Allam 2026-09-04 15:48:11 +00:00
parent 305b00ab07
commit 3de3c6f30d
4 changed files with 108 additions and 39 deletions

View file

@ -81,13 +81,21 @@ affecting the agents that do the actual testing.
</ParamField>
<ParamField path="EXA_API_KEY" type="string">
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.
</ParamField>
<ParamField path="STRIX_WEB_SEARCH_PROVIDER" default="auto" type="string">
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.
</ParamField>
<ParamField path="STRIX_EXA_SEARCH_TYPE" default="auto" type="string">
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.
</ParamField>
<ParamField path="STRIX_EXA_NUM_RESULTS" default="5" type="integer">
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.
</ParamField>
<ParamField path="POSTMAN_API_KEY" type="string">
Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://<collection-uid>`), and Postman environments (`postman://<collection-uid>?env=<environment-uid>`) to resolve collection variables. Not needed when passing a local collection export file.
</ParamField>

View file

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

View file

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

View file

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