From 90acb01e883de8e72bac78d90652f485010dd722 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 18:37:31 +0000 Subject: [PATCH] fix(web_search): address Parallel provider review findings Co-authored-by: Rowan Aldean --- docs/advanced/configuration.mdx | 18 +-- docs/tools/overview.mdx | 2 +- strix/config/settings.py | 5 + strix/interface/environment.py | 7 +- strix/tools/web_search/tool.py | 115 ++++++++++++++---- tests/test_web_search.py | 200 +++++++++++++++++++++++++++++++- 6 files changed, 310 insertions(+), 37 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 38e79e500..e964c87f9 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -137,7 +137,7 @@ affecting the agents that do the actual testing. - 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. @@ -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`. - + 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" + ``` + 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-..." diff --git a/docs/tools/overview.mdx b/docs/tools/overview.mdx index 466d91af6..d04ff0be4 100644 --- a/docs/tools/overview.mdx +++ b/docs/tools/overview.mdx @@ -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 | diff --git a/strix/config/settings.py b/strix/config/settings.py index 35314598b..122f234fa 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -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 diff --git a/strix/interface/environment.py b/strix/interface/environment.py index e7ee0634d..fe293c439 100644 --- a/strix/interface/environment.py +++ b/strix/interface/environment.py @@ -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") diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py index 6717dd4a3..daee9874f 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -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 diff --git a/tests/test_web_search.py b/tests/test_web_search.py index 20e2b2134..5a0efb0b9 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -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,