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