feat(web_search): default Exa to per-result summaries and add web_get_contents drill-down

This commit is contained in:
Ahmed Allam 2026-09-04 16:33:14 +00:00
parent 3de3c6f30d
commit a4fa849f59
5 changed files with 396 additions and 69 deletions

View file

@ -81,7 +81,7 @@ 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 `/search` endpoint. This is the preferred web search provider.
API key for Exa. Enables real-time web search through the Exa `/search` endpoint. Exa also powers the `web_get_contents` tool, which fetches the full text of a page through the Exa `/contents` endpoint. This is the preferred web search provider.
</ParamField>
<ParamField path="STRIX_WEB_SEARCH_PROVIDER" default="auto" type="string">
@ -93,7 +93,11 @@ affecting the agents that do the actual testing.
</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.
Number of Exa results to return, from `1` to `100`. This setting applies only to the Exa provider.
</ParamField>
<ParamField path="STRIX_EXA_CONTENT_MODE" default="summary" type="string">
Content that each Exa result includes: `summary` or `highlights`. With `summary`, each result includes a short security-focused summary of the page. With `highlights`, each result includes verbatim query-relevant excerpts. To read a full page, the agent calls `web_get_contents` with the result URL. This setting applies only to the Exa provider.
</ParamField>
<ParamField path="POSTMAN_API_KEY" type="string">

View file

@ -69,7 +69,7 @@ from strix.tools.todo.tools import (
mark_todo_pending,
update_todo,
)
from strix.tools.web_search.tool import web_search
from strix.tools.web_search.tool import web_get_contents, web_search
if TYPE_CHECKING:
@ -579,6 +579,7 @@ _BASE_TOOLS: tuple[Tool, ...] = (
save_threat_model,
amend_threat_model,
web_search,
web_get_contents,
create_vulnerability_report,
create_dependency_report,
update_vulnerability_report,

View file

@ -122,6 +122,7 @@ class TelemetrySettings(BaseSettings):
WebSearchProvider = Literal["auto", "perplexity", "exa"]
ExaSearchType = Literal["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"]
ExaContentMode = Literal["summary", "highlights"]
class IntegrationSettings(BaseSettings):
@ -151,6 +152,10 @@ class IntegrationSettings(BaseSettings):
le=100,
alias="STRIX_EXA_NUM_RESULTS",
)
exa_content_mode: ExaContentMode = Field(
default="summary",
alias="STRIX_EXA_CONTENT_MODE",
)
postman_api_key: str | None = Field(
default=None,
alias="POSTMAN_API_KEY",

View file

@ -1,11 +1,11 @@
"""``web_search`` — security-focused web search (Exa or Perplexity)."""
"""Security-focused web research tools (Exa or Perplexity)."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
import requests
from agents import RunContextWrapper, function_tool
@ -13,6 +13,10 @@ from agents import RunContextWrapper, function_tool
from strix.config import load_settings
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
@ -57,6 +61,20 @@ def _perplexity_content(api_key: str, query: str) -> str:
_EXA_HIGHLIGHT_MAX_CHARS = 750
_EXA_PAGE_MAX_CHARS = 20000
_EXA_MAX_CONTENT_URLS = 10
_EXA_SUMMARY_PROMPT = (
"Summarize this page for a penetration tester. Keep concrete technical detail: "
"affected products and exact versions, CVE and CWE identifiers, CVSS scores, "
"exploitation preconditions, payloads or commands, and mitigations. "
"Leave out marketing copy and navigation text."
)
def _exa_search_contents(content_mode: str) -> dict[str, Any]:
if content_mode == "highlights":
return {"highlights": {"maxCharacters": _EXA_HIGHLIGHT_MAX_CHARS}}
return {"summary": {"query": _EXA_SUMMARY_PROMPT}}
def _exa_result_block(result: dict[str, Any]) -> str | None:
@ -65,6 +83,9 @@ def _exa_result_block(result: dict[str, Any]) -> str | None:
return None
title = str(result.get("title") or result_url)
parts = [f"### {title}\n{result_url}"]
summary = str(result.get("summary") or "").strip()
if summary:
parts.append(summary)
highlights = result.get("highlights") or []
if isinstance(highlights, list):
excerpts = [str(item).strip() for item in highlights if str(item).strip()]
@ -73,31 +94,70 @@ def _exa_result_block(result: dict[str, Any]) -> str | None:
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}",
"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()
results: list[Any] = body.get("results") or []
def _exa_page_block(result: dict[str, Any]) -> str | None:
result_url = str(result.get("url") or result.get("id") or "")
text = str(result.get("text") or "").strip()
if not result_url or not text:
return None
if len(text) > _EXA_PAGE_MAX_CHARS:
text = f"{text[:_EXA_PAGE_MAX_CHARS]}\n[truncated at {_EXA_PAGE_MAX_CHARS} characters]"
title = str(result.get("title") or result_url)
return f"### {title}\n{result_url}\n\n{text}"
def _exa_blocks(
results: list[Any],
render: Callable[[dict[str, Any]], str | None],
) -> list[str]:
blocks: list[str] = []
for result in results:
if not isinstance(result, dict):
continue
block = _exa_result_block(cast("dict[str, Any]", result))
block = render(cast("dict[str, Any]", result))
if block:
blocks.append(block)
return blocks
def _exa_post(api_key: str, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
with requests.post(endpoint, headers=headers, json=payload, timeout=300) as response:
response.raise_for_status()
body: dict[str, Any] = response.json()
return body
def _exa_content(
api_key: str,
query: str,
search_type: str,
num_results: int,
content_mode: str,
) -> str:
body = _exa_post(
api_key,
"https://api.exa.ai/search",
{
"query": f"{_SYSTEM_PROMPT}\n\n{query}",
"type": search_type,
"numResults": num_results,
"contents": _exa_search_contents(content_mode),
},
)
blocks = _exa_blocks(body.get("results") or [], _exa_result_block)
if not blocks:
raise ValueError("Exa response has no results")
return "\n\n".join(blocks)
def _exa_page_text(api_key: str, urls: list[str]) -> str:
body = _exa_post(api_key, "https://api.exa.ai/contents", {"urls": urls, "text": True})
blocks = _exa_blocks(body.get("results") or [], _exa_page_block)
if not blocks:
raise ValueError("Exa returned no page contents")
return "\n\n".join(blocks)
def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs its own return
integrations: Any,
) -> tuple[str, str] | dict[str, Any]:
@ -133,7 +193,35 @@ def _not_configured_error(missing: str) -> dict[str, Any]:
}
def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error class needs its own sanitized return
def _guarded_call( # noqa: PLR0911 - each error class needs its own sanitized return
tool: str,
rejected_hint: str,
fetch: Callable[[], str],
) -> str | dict[str, Any]:
"""Run a provider call and translate any failure into a sanitized error dict."""
try:
return fetch()
except requests.exceptions.Timeout:
logger.warning("%s timed out", tool)
return {"success": False, "error": f"{tool} timed out. Try again or narrow the request"}
except requests.exceptions.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else None
logger.exception("%s HTTP error status=%s", tool, status)
if status is not None and 400 <= status < 500:
return {"success": False, "error": rejected_hint}
return {"success": False, "error": f"{tool} service is unavailable. Try again later"}
except requests.exceptions.RequestException:
logger.exception("%s network error", tool)
return {"success": False, "error": f"{tool} network error. Try again later"}
except (KeyError, IndexError, ValueError):
logger.exception("%s response shape unexpected", tool)
return {"success": False, "error": f"{tool} returned an unexpected response. Try again"}
except Exception:
logger.exception("%s failed", tool)
return {"success": False, "error": f"{tool} failed unexpectedly"}
def _do_search(query: str) -> dict[str, Any]:
if not query or not query.strip():
return {"success": False, "error": "Query cannot be empty"}
@ -144,63 +232,75 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas
provider, api_key = resolved
logger.info("web_search provider=%s query (len=%d): %s", provider, len(query), query[:120])
try:
def fetch() -> str:
if provider == "exa":
content = _exa_content(
return _exa_content(
api_key,
query,
integrations.exa_search_type,
integrations.exa_num_results,
integrations.exa_content_mode,
)
else:
content = _perplexity_content(api_key, query)
except requests.exceptions.Timeout:
logger.warning("web_search timed out")
return _perplexity_content(api_key, query)
outcome = _guarded_call(
"Web search",
(
"Web search rejected the query. Refine it "
"(more specific, shorter, no unusual characters) and retry"
),
fetch,
)
if isinstance(outcome, dict):
return outcome
return {
"success": True,
"query": query,
"provider": provider,
"content": outcome,
}
def _do_get_contents(urls: list[str]) -> dict[str, Any]:
cleaned = [url.strip() for url in urls if url and url.strip()]
if not cleaned:
return {"success": False, "error": "Provide at least one URL"}
if len(cleaned) > _EXA_MAX_CONTENT_URLS:
return {
"success": False,
"error": "Web search timed out. Try again or shorten the query",
"error": f"Too many URLs. Pass at most {_EXA_MAX_CONTENT_URLS} per call",
}
except requests.exceptions.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else None
logger.exception("web_search HTTP error status=%s", status)
if status is not None and 400 <= status < 500:
return {
"success": False,
"error": (
"Web search rejected the query. Refine it "
"(more specific, shorter, no unusual characters) and retry"
),
}
integrations = load_settings().integrations
api_key = integrations.exa_api_key
if not api_key:
return _not_configured_error("EXA_API_KEY")
if integrations.web_search_provider == "perplexity":
logger.warning("web_get_contents invoked while the provider is pinned to Perplexity")
return {
"success": False,
"error": "Web search service is unavailable. Try again later",
}
except requests.exceptions.RequestException:
logger.exception("web_search network error")
return {
"success": False,
"error": "Web search network error. Try again later",
}
except (KeyError, IndexError, ValueError):
logger.exception("web_search response shape unexpected")
return {
"success": False,
"error": "Web search returned an unexpected response. Try again",
}
except Exception:
logger.exception("web_search failed")
return {
"success": False,
"error": "Web search failed unexpectedly",
}
else:
return {
"success": True,
"query": query,
"provider": provider,
"content": content,
"error": (
"Page fetching needs the Exa provider "
"(operator pinned STRIX_WEB_SEARCH_PROVIDER to perplexity). "
"Use web_search instead"
),
}
logger.info("web_get_contents urls=%d", len(cleaned))
outcome = _guarded_call(
"Page fetch",
"Page fetch was rejected. Check the URLs are complete, public, and correctly formed",
lambda: _exa_page_text(api_key, cleaned),
)
if isinstance(outcome, dict):
return outcome
return {
"success": True,
"urls": cleaned,
"provider": "exa",
"content": outcome,
}
@function_tool(timeout=330)
async def web_search(ctx: RunContextWrapper, query: str) -> str:
@ -234,6 +334,13 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str:
exploits, Kali-compatible tooling, and concrete code/command
examples.
With the Exa provider you get a ranked list of results, each with a
title, URL, and a short security-focused summary (or verbatim
highlights when ``STRIX_EXA_CONTENT_MODE=highlights``). Read the
result you need, then call ``web_get_contents`` with its URL to pull
the full page text when a summary is not enough. With Perplexity you
get a single synthesized cited answer.
**Good example queries** (each is a full sentence, names a
version/product, and asks one concrete thing):
@ -261,3 +368,28 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str:
"""
result = await asyncio.to_thread(_do_search, query)
return json.dumps(result, ensure_ascii=False, default=str)
@function_tool(timeout=330)
async def web_get_contents(ctx: RunContextWrapper, urls: list[str]) -> str:
"""Fetch the full, cleaned text of specific web pages (Exa only).
Use this as the drill-down step after ``web_search``: when a result's
summary or highlights are not enough, pass that result's URL here to
read the whole page. Good for reading a full advisory, a CVE writeup,
an exploit proof-of-concept, or vendor documentation end to end.
Prefer ``web_search`` first to find the right pages, then fetch only
the few URLs worth reading in full — each page can be large, so avoid
fetching many pages you do not need.
This tool needs the Exa provider (``EXA_API_KEY``). When the operator
pins the provider to Perplexity, it returns an error and you should
use ``web_search`` instead.
Args:
urls: The page URLs to fetch, at most 10 per call. Use complete,
public URLs (for example the ones returned by ``web_search``).
"""
result = await asyncio.to_thread(_do_get_contents, urls)
return json.dumps(result, ensure_ascii=False, default=str)

View file

@ -1,4 +1,4 @@
"""Tests for web_search provider selection and the Exa backend."""
"""Tests for web_search/web_get_contents provider selection and the Exa backend."""
from __future__ import annotations
@ -104,7 +104,7 @@ def test_exa_content_renders_results_with_highlights(monkeypatch: pytest.MonkeyP
monkeypatch.setattr(requests, "post", fake_post)
content = tool._exa_content("ek", "OpenSSH 7.4 RCE?", "auto", 5)
content = tool._exa_content("ek", "OpenSSH 7.4 RCE?", "auto", 5, "highlights")
assert captured["url"] == "https://api.exa.ai/search"
assert captured["headers"]["x-api-key"] == "ek"
@ -112,13 +112,42 @@ def test_exa_content_renders_results_with_highlights(monkeypatch: pytest.MonkeyP
assert captured["json"]["type"] == "auto"
assert captured["json"]["numResults"] == 5
assert captured["json"]["contents"]["highlights"]["maxCharacters"] > 0
assert "summary" not in captured["json"]["contents"]
assert content == (
"### 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_renders_a_result_without_highlights(
def test_exa_content_defaults_to_summary_and_renders_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def fake_post(_url: str, **kwargs: Any) -> _FakeResponse:
captured["json"] = kwargs["json"]
return _FakeResponse(
{
"results": [
{
"url": "https://nvd.example/cve",
"title": "NVD entry",
"summary": " CVE-2024-0001 is a heap overflow. ",
},
],
}
)
monkeypatch.setattr(requests, "post", fake_post)
content = tool._exa_content("ek", "q", "auto", 5, "summary")
assert captured["json"]["contents"]["summary"]["query"]
assert "highlights" not in captured["json"]["contents"]
assert content == "### NVD entry\nhttps://nvd.example/cve\nCVE-2024-0001 is a heap overflow."
def test_exa_content_renders_a_result_without_contents(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
@ -128,7 +157,7 @@ def test_exa_content_renders_a_result_without_highlights(
{"results": [{"url": "https://ex.example", "title": "Ex"}]}
),
)
assert tool._exa_content("ek", "q", "auto", 5) == "### Ex\nhttps://ex.example"
assert tool._exa_content("ek", "q", "auto", 5, "summary") == "### Ex\nhttps://ex.example"
@pytest.mark.parametrize("body", [{}, {"results": None}, {"results": []}, {"results": ["x"]}])
@ -137,7 +166,28 @@ def test_exa_content_rejects_empty_results(
) -> None:
monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body))
with pytest.raises(ValueError, match="no results"):
tool._exa_content("ek", "q", "auto", 5)
tool._exa_content("ek", "q", "auto", 5, "summary")
def test_exa_content_mode_is_configurable(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
class _Settings:
integrations = IntegrationSettings(
EXA_API_KEY="ek",
STRIX_EXA_CONTENT_MODE="highlights",
)
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 "highlights" in captured["json"]["contents"]
assert "summary" not in captured["json"]["contents"]
def test_do_search_reports_empty_exa_results_as_unexpected(
@ -198,6 +248,141 @@ def test_exa_search_type_and_num_results_are_configurable(
assert captured["json"]["numResults"] == 3
def test_exa_page_text_requests_full_text_and_renders_pages(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
captured["url"] = url
captured["headers"] = kwargs["headers"]
captured["json"] = kwargs["json"]
return _FakeResponse(
{
"results": [
{
"url": "https://nvd.example/cve",
"title": "NVD entry",
"text": " Full advisory body. ",
},
{"url": "https://empty.example", "text": " "},
"not-a-dict",
{"text": "no url"},
],
}
)
monkeypatch.setattr(requests, "post", fake_post)
content = 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."
def test_exa_page_text_truncates_a_long_page(monkeypatch: pytest.MonkeyPatch) -> None:
body = "A" * (tool._EXA_PAGE_MAX_CHARS + 500)
monkeypatch.setattr(
requests,
"post",
lambda *_a, **_kw: _FakeResponse(
{"results": [{"url": "https://ex.example", "text": body}]}
),
)
content = tool._exa_page_text("ek", ["https://ex.example"])
assert "truncated at" in content
assert content.count("A") == tool._EXA_PAGE_MAX_CHARS
@pytest.mark.parametrize("body", [{}, {"results": []}, {"results": [{"url": "u"}]}])
def test_exa_page_text_rejects_pages_without_text(
monkeypatch: pytest.MonkeyPatch, body: dict[str, Any]
) -> None:
monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body))
with pytest.raises(ValueError, match="no page contents"):
tool._exa_page_text("ek", ["https://ex.example"])
@pytest.mark.parametrize("urls", [[], ["", " "]])
def test_do_get_contents_requires_a_url(urls: list[str]) -> None:
result = tool._do_get_contents(urls)
assert result["success"] is False
assert "at least one URL" in result["error"]
def test_do_get_contents_caps_the_url_count() -> None:
urls = [f"https://ex{index}.example" for index in range(tool._EXA_MAX_CONTENT_URLS + 1)]
result = tool._do_get_contents(urls)
assert result["success"] is False
assert "Too many URLs" in result["error"]
def test_do_get_contents_needs_an_exa_key(monkeypatch: pytest.MonkeyPatch) -> None:
class _Settings:
integrations = IntegrationSettings(PERPLEXITY_API_KEY="pk")
monkeypatch.setattr(tool, "load_settings", _Settings)
result = tool._do_get_contents(["https://ex.example"])
assert result["success"] is False
assert "EXA_API_KEY" in result["error"]
def test_do_get_contents_refuses_a_perplexity_pinned_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _Settings:
integrations = IntegrationSettings(
EXA_API_KEY="ek",
PERPLEXITY_API_KEY="pk",
STRIX_WEB_SEARCH_PROVIDER="perplexity",
)
monkeypatch.setattr(tool, "load_settings", _Settings)
result = tool._do_get_contents(["https://ex.example"])
assert result["success"] is False
assert "web_search" in result["error"]
def test_do_get_contents_returns_page_text(monkeypatch: pytest.MonkeyPatch) -> None:
class _Settings:
integrations = IntegrationSettings(EXA_API_KEY="ek")
monkeypatch.setattr(tool, "load_settings", _Settings)
monkeypatch.setattr(tool, "_exa_page_text", lambda *_a: "page")
result = tool._do_get_contents([" https://ex.example "])
assert result == {
"success": True,
"urls": ["https://ex.example"],
"provider": "exa",
"content": "page",
}
def test_do_get_contents_sanitizes_a_network_error(monkeypatch: pytest.MonkeyPatch) -> None:
class _Settings:
integrations = IntegrationSettings(EXA_API_KEY="ek")
def boom(*_args: Any, **_kwargs: Any) -> None:
raise requests.exceptions.ConnectionError
monkeypatch.setattr(tool, "load_settings", _Settings)
monkeypatch.setattr(requests, "post", boom)
result = tool._do_get_contents(["https://ex.example"])
assert result["success"] is False
assert "network error" in result["error"]
assert "ek" not in result["error"]
def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch) -> None:
class _Settings:
integrations = IntegrationSettings(EXA_API_KEY="ek")