fix(web_search): address Parallel provider review findings

Co-authored-by: Rowan Aldean <RowanAldean@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-10-29 18:37:31 +00:00
parent 584199901c
commit 90acb01e88
No known key found for this signature in database
6 changed files with 310 additions and 37 deletions

View file

@ -137,7 +137,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. 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.
API key for Exa. Enables real-time web search through the Exa `/search` endpoint. Exa powers the `web_get_contents` tool through the Exa `/contents` endpoint unless Parallel is pinned. This is the preferred web search provider.
</ParamField>
<ParamField path="STRIX_WEB_SEARCH_PROVIDER" default="auto" type="string">
@ -153,14 +153,14 @@ affecting the agents that do the actual testing.
those headers alone exceed the budget.
Queries may contain up to 5,000 characters; the full query is the search objective,
with its first 200 characters also supplied as a search query.
This uses the authenticated API, not the free Search MCP. `web_get_contents`
remains Exa-only and requires `EXA_API_KEY`.
</ParamField>
This uses the authenticated API, not the free Search MCP. When Parallel is pinned,
`web_get_contents` uses the Parallel `/v1/extract` endpoint with the same key.
```bash
export STRIX_WEB_SEARCH_PROVIDER="parallel"
export PARALLEL_API_KEY="your-parallel-api-key"
```
```bash
export STRIX_WEB_SEARCH_PROVIDER="parallel"
export PARALLEL_API_KEY="your-parallel-api-key"
```
</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.
@ -253,7 +253,7 @@ strix --target ./app --config /path/to/config.json
export STRIX_LLM="openrouter/z-ai/glm-5.3"
export LLM_API_KEY="sk-..."
# Optional: Enable web search (Exa preferred, Perplexity supported)
# Optional: Enable web search (Exa preferred; Perplexity and opt-in Parallel supported)
export EXA_API_KEY="..."
export PERPLEXITY_API_KEY="pplx-..."

View file

@ -28,6 +28,6 @@ Strix agents use specialized tools to test your applications like a real penetra
| -------------- | ---------------------------------------- |
| Python Runtime | Write and execute custom exploit scripts |
| File Editor | Read and modify source code |
| Web Search | Real-time OSINT with Exa or Perplexity |
| Web Search | Real-time OSINT with Exa, Perplexity, or Parallel |
| Notes | Document findings during the scan |
| Reporting | Generate vulnerability reports with PoCs |

View file

@ -221,6 +221,11 @@ class IntegrationSettings(BaseSettings):
repr=False,
)
@field_validator("web_search_provider", "exa_search_type", mode="before")
@classmethod
def _normalize_case(cls, value: object) -> object:
return _lowercase(value)
class ViewerSettings(BaseSettings):
model_config = _BASE_CONFIG

View file

@ -36,8 +36,8 @@ def _missing_web_search_vars(integrations: IntegrationSettings) -> list[str]:
def validate_environment() -> None:
logger.info("Validating environment")
console = Console()
missing_required_vars = []
missing_optional_vars = []
missing_required_vars: list[str] = []
missing_optional_vars: list[str] = []
settings = load_settings()
@ -114,7 +114,8 @@ def validate_environment() -> None:
style="white",
)
elif var == "PARALLEL_API_KEY":
error_text.append("• PARALLEL_API_KEY", style="bold cyan")
error_text.append("• ", style="white")
error_text.append("PARALLEL_API_KEY", style="bold cyan")
error_text.append(" - API key for Parallel web search\n", style="white")
elif var == "STRIX_REASONING_EFFORT":
error_text.append("• ", style="white")

View file

@ -137,17 +137,18 @@ _PARALLEL_MAX_CHARS = 12000
def _parallel_result_content(results: list[tuple[str, str]]) -> str:
# Reserve every source header before spending the remaining budget on excerpts.
header_chars = sum(len(header) for header, _ in results) + 2 * (len(results) - 1)
remaining = max(0, _PARALLEL_MAX_CHARS - header_chars)
blocks: list[str] = []
truncated = False
for header, excerpts in results:
excerpt = excerpts[: max(0, remaining - 2)]
for index, (header, excerpts) in enumerate(results):
share = remaining // (len(results) - index)
excerpt_slice = excerpts[: max(0, share - 2)]
excerpt = excerpt_slice.rstrip()
blocks.append(f"{header}\n\n{excerpt}" if excerpt else header)
if excerpt:
remaining -= len(excerpt) + 2
truncated |= len(excerpt) < len(excerpts)
truncated |= len(excerpt_slice) < len(excerpts)
content = "\n\n".join(blocks)
if truncated:
content += "\n[excerpts truncated to preserve source links]"
@ -178,14 +179,16 @@ def _parallel_content(api_key: str, query: str) -> str:
return "No web search results found."
blocks: list[tuple[str, str]] = []
for item in results[:5]:
for item in results:
if not isinstance(item, dict):
continue
result = cast("dict[str, Any]", item)
if not result.get("url"):
raw_url = result.get("url")
if not isinstance(raw_url, str) or not (url := raw_url.strip()):
continue
url = str(result["url"])
header = f"### {result.get('title') or url}\n{url}"
raw_title = result.get("title")
title = raw_title.strip() if isinstance(raw_title, str) else ""
header = f"### {title or url}\n{url}"
parts: list[str] = []
excerpts: list[Any] | None = result.get("excerpts")
if isinstance(excerpts, list):
@ -193,6 +196,8 @@ def _parallel_content(api_key: str, query: str) -> str:
text.strip() for text in excerpts if isinstance(text, str) and text.strip()
)
blocks.append((header, "\n\n".join(parts)))
if len(blocks) == 5:
break
if not blocks:
raise ValueError("Parallel response has no usable results")
return _parallel_result_content(blocks)
@ -226,6 +231,54 @@ def _exa_page_text(api_key: str, urls: list[str]) -> tuple[str, set[str]]:
return "\n\n".join(blocks), fetched
def _parallel_page_text(api_key: str, urls: list[str]) -> tuple[str, set[str]]:
"""Fetch page text and report which of the requested URLs Parallel returned."""
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
payload = {"urls": urls, "advanced_settings": {"full_content": True}}
with requests.post(
"https://api.parallel.ai/v1/extract",
headers=headers,
json=payload,
timeout=300,
) as response:
response.raise_for_status()
body = response.json()
if not isinstance(body, dict):
raise TypeError("Parallel Extract response must be an object")
results: list[Any] | None = cast("dict[str, Any]", body).get("results")
if not isinstance(results, list):
raise TypeError("Parallel Extract response has no results list")
blocks: list[str] = []
fetched: set[str] = set()
for item in results:
if not isinstance(item, dict):
continue
result = cast("dict[str, Any]", item)
raw_url = result.get("url")
if not isinstance(raw_url, str) or not (url := raw_url.strip()):
continue
raw_content = result.get("full_content")
content = raw_content.strip() if isinstance(raw_content, str) else ""
if not content:
excerpts: list[Any] | None = result.get("excerpts")
if isinstance(excerpts, list):
content = "\n\n".join(
excerpt.strip()
for excerpt in excerpts
if isinstance(excerpt, str) and excerpt.strip()
)
if not content:
continue
raw_title = result.get("title")
title = raw_title.strip() if isinstance(raw_title, str) else ""
blocks.append(f"### {title or url}\n{url}\n\n{content}")
fetched.add(_normalize_url(url))
if not blocks:
raise ValueError("Parallel Extract returned no page contents")
return "\n\n".join(blocks), fetched
def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs its own return
integrations: Any,
) -> tuple[str, str] | dict[str, Any]:
@ -294,7 +347,8 @@ def _guarded_call[T]( # noqa: PLR0911 - each error class needs its own sanitize
def _do_search(query: str) -> dict[str, Any]:
if not query or not query.strip():
query = query.strip()
if not query:
return {"success": False, "error": "Query cannot be empty"}
integrations = load_settings().integrations
@ -342,7 +396,9 @@ def _do_search(query: str) -> dict[str, Any]:
}
def _do_get_contents(urls: list[str]) -> dict[str, Any]:
def _do_get_contents( # noqa: PLR0911 - validation and provider errors return directly
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"}
@ -353,9 +409,6 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
}
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 {
@ -367,11 +420,26 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
),
}
logger.info("web_get_contents urls=%d", len(cleaned))
provider = integrations.web_search_provider
if provider == "parallel":
api_key = integrations.parallel_api_key
if not api_key:
return _not_configured_error("PARALLEL_API_KEY")
page_fetch = _parallel_page_text
source_name = "Parallel Extract"
else:
api_key = integrations.exa_api_key
if not api_key:
return _not_configured_error("EXA_API_KEY")
provider = "exa"
page_fetch = _exa_page_text
source_name = "Exa"
logger.info("web_get_contents provider=%s urls=%d", provider, 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),
lambda: page_fetch(api_key, cleaned),
)
if isinstance(outcome, dict):
return outcome
@ -380,7 +448,7 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
result: dict[str, Any] = {
"success": True,
"urls": [url for url in cleaned if url not in missing],
"provider": "exa",
"provider": provider,
"content": content,
}
if missing:
@ -389,7 +457,8 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
)
result["failed_urls"] = missing
result["warning"] = (
f"Exa returned no content for {len(missing)} of {len(cleaned)} requested URLs. "
f"{source_name} returned no content for {len(missing)} of {len(cleaned)} requested "
"URLs. "
"Those pages are missing from the content below"
)
return result
@ -435,7 +504,8 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str:
With Parallel you get ranked titles, source URLs, and relevant excerpts,
capped to keep the response concise. These are source excerpts, not a
synthesized answer. ``web_get_contents`` still requires an Exa key.
synthesized answer. ``web_get_contents`` uses Parallel Extract when the
operator pins Parallel.
**Good example queries** (each is a full sentence, names a
version/product, and asks one concrete thing):
@ -468,7 +538,7 @@ async def web_search(ctx: RunContextWrapper, query: str) -> 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).
"""Fetch the full, cleaned text of specific web pages (Exa or Parallel Extract).
Use this as the drill-down step after ``web_search``: when a result's
summary is not enough, pass that result's URL here to read the whole
@ -479,9 +549,10 @@ async def web_get_contents(ctx: RunContextWrapper, urls: list[str]) -> str:
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.
This tool uses Parallel Extract (``PARALLEL_API_KEY``) when the operator
pins Parallel. Otherwise it uses Exa (``EXA_API_KEY``). When the operator
pins Perplexity, it returns an error before checking any Exa key; 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

View file

@ -300,12 +300,67 @@ def test_do_get_contents_needs_an_exa_key(monkeypatch: pytest.MonkeyPatch) -> No
assert "EXA_API_KEY" in result["error"]
def test_do_get_contents_refuses_a_perplexity_pinned_provider(
def test_do_get_contents_uses_parallel_extract_when_provider_is_pinned(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _Settings:
integrations = IntegrationSettings(
EXA_API_KEY="ek",
PARALLEL_API_KEY="parallel-key",
STRIX_WEB_SEARCH_PROVIDER="parallel",
)
monkeypatch.setattr(tool, "load_settings", _Settings)
monkeypatch.setattr(
tool,
"_parallel_page_text",
lambda *_a: ("parallel page", {"https://ex.example"}),
raising=False,
)
monkeypatch.setattr(
tool,
"_exa_page_text",
lambda *_a: pytest.fail("Pinned Parallel must not send URLs to Exa"),
)
result = tool._do_get_contents(["https://ex.example"])
assert result == {
"success": True,
"urls": ["https://ex.example"],
"provider": "parallel",
"content": "parallel page",
}
def test_do_get_contents_pinned_parallel_requires_parallel_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _Settings:
integrations = IntegrationSettings(
EXA_API_KEY="ek",
STRIX_WEB_SEARCH_PROVIDER="parallel",
)
monkeypatch.setattr(tool, "load_settings", _Settings)
monkeypatch.setattr(
tool,
"_exa_page_text",
lambda *_a: pytest.fail("Pinned Parallel must not fall back to Exa"),
)
result = tool._do_get_contents(["https://ex.example"])
assert result["success"] is False
assert "PARALLEL_API_KEY" in result["error"]
assert "EXA_API_KEY" not in result["error"]
def test_do_get_contents_refuses_a_perplexity_pinned_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _Settings:
integrations = IntegrationSettings(
PERPLEXITY_API_KEY="pk",
STRIX_WEB_SEARCH_PROVIDER="perplexity",
)
@ -316,6 +371,7 @@ def test_do_get_contents_refuses_a_perplexity_pinned_provider(
assert result["success"] is False
assert "web_search" in result["error"]
assert "EXA_API_KEY" not in result["error"]
def test_do_get_contents_returns_page_text(monkeypatch: pytest.MonkeyPatch) -> None:
@ -415,6 +471,61 @@ def test_do_get_contents_sanitizes_a_network_error(monkeypatch: pytest.MonkeyPat
assert "ek" not in result["error"]
def test_parallel_page_text_requests_full_content_and_renders_pages(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
captured.update(url=url, **kwargs)
return _FakeResponse(
{
"results": [
{
"url": "https://vendor.example/advisory",
"title": " Vendor advisory ",
"excerpts": ["Focused excerpt."],
"full_content": " Full advisory body. ",
},
{
"url": "https://vendor.example/docs",
"title": None,
"excerpts": [" Excerpt fallback. "],
"full_content": None,
},
{"url": " ", "excerpts": ["Malformed."]},
],
"errors": [{"url": "https://blocked.example", "error_type": "fetch_error"}],
}
)
monkeypatch.setattr(requests, "post", fake_post)
content, fetched = tool._parallel_page_text(
"parallel-key",
["https://vendor.example/advisory", "https://vendor.example/docs"],
)
assert captured["url"] == "https://api.parallel.ai/v1/extract"
assert captured["headers"] == {
"x-api-key": "parallel-key",
"Content-Type": "application/json",
}
assert captured["json"] == {
"urls": ["https://vendor.example/advisory", "https://vendor.example/docs"],
"advanced_settings": {"full_content": True},
}
assert captured["timeout"] == 300
assert content == (
"### Vendor advisory\nhttps://vendor.example/advisory\n\nFull advisory body.\n\n"
"### https://vendor.example/docs\nhttps://vendor.example/docs\n\nExcerpt fallback."
)
assert fetched == {
"https://vendor.example/advisory",
"https://vendor.example/docs",
}
def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch) -> None:
class _Settings:
integrations = IntegrationSettings(EXA_API_KEY="ek")
@ -459,6 +570,18 @@ def test_parallel_selection_is_opt_in(
assert result == expected
def test_web_search_provider_and_exa_type_are_case_insensitive() -> None:
integrations = IntegrationSettings.model_validate(
{
"STRIX_WEB_SEARCH_PROVIDER": " PARALLEL ",
"STRIX_EXA_SEARCH_TYPE": "DEEP-REASONING",
}
)
assert integrations.web_search_provider == "parallel"
assert integrations.exa_search_type == "deep-reasoning"
@pytest.fixture
def _parallel_settings(monkeypatch: pytest.MonkeyPatch) -> None:
class _Settings:
@ -492,7 +615,7 @@ def test_parallel_request_and_tool_response(monkeypatch: pytest.MonkeyPatch) ->
)
monkeypatch.setattr(requests, "post", fake_post)
query = "Find affected versions and mitigations in the vendor advisory. " * 4
query = ("Find affected versions and mitigations in the vendor advisory. " * 4).strip()
result = tool._do_search(query)
assert captured["url"] == "https://api.parallel.ai/v1/search"
assert captured["headers"] == {
@ -517,6 +640,25 @@ def test_parallel_request_and_tool_response(monkeypatch: pytest.MonkeyPatch) ->
}
@pytest.mark.usefixtures("_parallel_settings")
def test_parallel_search_strips_query_before_validation_and_request(
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://vendor.example"}]})
monkeypatch.setattr(requests, "post", fake_post)
result = tool._do_search(" " * 250 + "vendor advisory ")
assert result["success"] is True
assert result["query"] == "vendor advisory"
assert captured["json"]["objective"] == "vendor advisory"
assert captured["json"]["search_queries"] == ["vendor advisory"]
@pytest.mark.usefixtures("_parallel_settings")
@pytest.mark.parametrize(
("body", "success", "text"),
@ -556,6 +698,60 @@ def test_parallel_caps_output(monkeypatch: pytest.MonkeyPatch) -> None:
assert len(result) == 12000 + len(notice)
def test_parallel_filters_before_capping_results(monkeypatch: pytest.MonkeyPatch) -> None:
malformed: list[Any] = [
"not an object",
{"url": " ", "title": "Blank URL"},
{"title": "Missing URL"},
]
usable = [
{
"url": f" https://vendor.example/{index} ",
"title": " " if index == 0 else f" Result {index} ",
"excerpts": [f"Excerpt {index}."],
}
for index in range(6)
]
monkeypatch.setattr(
requests,
"post",
lambda *_a, **_kw: _FakeResponse({"results": [*malformed, *usable]}),
)
content = tool._parallel_content("key", "vendor advisories")
for index in range(5):
assert f"https://vendor.example/{index}" in content
assert "https://vendor.example/5" not in content
assert "### https://vendor.example/0\nhttps://vendor.example/0" in content
assert "Blank URL" not in content
def test_parallel_spreads_excerpt_budget_and_strips_trailing_whitespace(
monkeypatch: pytest.MonkeyPatch,
) -> None:
headers = ["### First\nhttps://first.example", "### Second\nhttps://second.example"]
header_text = "\n\n".join(headers)
monkeypatch.setattr(tool, "_PARALLEL_MAX_CHARS", len(header_text) + 24)
content = tool._parallel_result_content(
[(headers[0], "a" * 40 + "\n\n"), (headers[1], "b" * 40 + "\n\n")]
)
assert f"{headers[0]}\n\na" in content
assert f"{headers[1]}\n\nb" in content
assert "\n\n\n\n### Second" not in content
assert content.endswith("[excerpts truncated to preserve source links]")
def test_parallel_strips_trailing_excerpt_whitespace() -> None:
content = tool._parallel_result_content(
[("### Result\nhttps://result.example", "Complete excerpt.\n\n")]
)
assert content == "### Result\nhttps://result.example\n\nComplete excerpt."
@pytest.mark.parametrize("first_excerpt_size", [11920, 20000])
def test_parallel_truncation_keeps_all_source_headers(
monkeypatch: pytest.MonkeyPatch,