From cdf2c92d2b80405320cc2b127b1c392fb1a0a1ec Mon Sep 17 00:00:00 2001 From: Rowan Aldean <56801656+RowanAldean@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:18:16 +0100 Subject: [PATCH 1/4] feat(web_search): add opt-in Parallel provider --- docs/advanced/configuration.mdx | 18 ++- strix/config/settings.py | 7 +- strix/interface/environment.py | 9 ++ strix/tools/web_search/tool.py | 75 ++++++++++++- tests/test_config_loader.py | 25 +++++ tests/test_web_search.py | 187 +++++++++++++++++++++++++++++++- 6 files changed, 314 insertions(+), 7 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 247c0994ec..fac557e382 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -141,9 +141,25 @@ affecting the agents that do the actual testing. - 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. + Web search provider: `auto`, `perplexity`, `exa`, or `parallel`. With `auto`, Strix uses Exa when `EXA_API_KEY` is set, and Perplexity otherwise. Set an explicit provider to pin one. Parallel is opt-in and must be selected explicitly. + + API key for [Parallel Search](https://docs.parallel.ai/search/search-quickstart). + Set `STRIX_WEB_SEARCH_PROVIDER=parallel` to use it for the built-in `web_search` tool. + Requests use `/v1/search` in `fast` mode, returning up to five source URLs with + relevant excerpts. Output is capped at 12,000 characters plus a truncation notice. + 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`. + + +```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. diff --git a/strix/config/settings.py b/strix/config/settings.py index 9b216aa99a..35314598b6 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -179,7 +179,7 @@ class TelemetrySettings(BaseSettings): run_record_timings: bool = Field(default=False, alias="STRIX_RUN_RECORD_TIMINGS") -WebSearchProvider = Literal["auto", "perplexity", "exa"] +WebSearchProvider = Literal["auto", "perplexity", "exa", "parallel"] ExaSearchType = Literal["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"] @@ -196,6 +196,11 @@ class IntegrationSettings(BaseSettings): alias="EXA_API_KEY", repr=False, ) + parallel_api_key: str | None = Field( + default=None, + alias="PARALLEL_API_KEY", + repr=False, + ) web_search_provider: WebSearchProvider = Field( default="auto", alias="STRIX_WEB_SEARCH_PROVIDER", diff --git a/strix/interface/environment.py b/strix/interface/environment.py index 522067df84..e7ee0634d7 100644 --- a/strix/interface/environment.py +++ b/strix/interface/environment.py @@ -26,6 +26,8 @@ def _missing_web_search_vars(integrations: IntegrationSettings) -> list[str]: return [] if integrations.exa_api_key else ["EXA_API_KEY"] if integrations.web_search_provider == "perplexity": return [] if integrations.perplexity_api_key else ["PERPLEXITY_API_KEY"] + if integrations.web_search_provider == "parallel": + return [] if integrations.parallel_api_key else ["PARALLEL_API_KEY"] if integrations.exa_api_key or integrations.perplexity_api_key: return [] return ["EXA_API_KEY", "PERPLEXITY_API_KEY"] @@ -111,6 +113,9 @@ def validate_environment() -> None: " - API key for Exa web search (enables real-time research)\n", style="white", ) + elif var == "PARALLEL_API_KEY": + 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") error_text.append("STRIX_REASONING_EFFORT", style="bold cyan") @@ -137,6 +142,10 @@ def validate_environment() -> None: ) elif var == "EXA_API_KEY": error_text.append("export EXA_API_KEY='your-exa-key-here'\n", style="dim white") + elif var == "PARALLEL_API_KEY": + error_text.append( + "export PARALLEL_API_KEY='your-parallel-key-here'\n", style="dim white" + ) elif var == "STRIX_REASONING_EFFORT": error_text.append( "export STRIX_REASONING_EFFORT='high'\n", diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py index f525b1ea49..656701f358 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -1,4 +1,4 @@ -"""Security-focused web research tools (Exa or Perplexity).""" +"""Security-focused web research tools (Exa, Perplexity, or Parallel).""" from __future__ import annotations @@ -133,6 +133,55 @@ def _exa_content(api_key: str, query: str, search_type: str, num_results: int) - return "\n\n".join(blocks) +_PARALLEL_MAX_CHARS = 12000 + + +def _parallel_content(api_key: str, query: str) -> str: + with requests.post( + "https://api.parallel.ai/v1/search", + headers={"x-api-key": api_key, "Content-Type": "application/json"}, + json={ + "objective": query, + "search_queries": [query[:200]], + "mode": "fast", + "max_chars_total": _PARALLEL_MAX_CHARS, + "advanced_settings": {"max_results": 5}, + }, + timeout=300, + ) as response: + response.raise_for_status() + body = response.json() + if not isinstance(body, dict): + raise TypeError("Parallel response must be an object") + results: list[Any] | None = cast("dict[str, Any]", body).get("results") + if not isinstance(results, list): + raise TypeError("Parallel response has no results list") + if not results: + return "No web search results found." + + blocks: list[str] = [] + for item in results[:5]: + if not isinstance(item, dict): + continue + result = cast("dict[str, Any]", item) + if not result.get("url"): + continue + url = str(result["url"]) + parts = [f"### {result.get('title') or url}\n{url}"] + excerpts: list[Any] | None = result.get("excerpts") + if isinstance(excerpts, list): + parts.extend( + text.strip() for text in excerpts if isinstance(text, str) and text.strip() + ) + blocks.append("\n\n".join(parts)) + if not blocks: + raise ValueError("Parallel response has no usable results") + content = "\n\n".join(blocks) + if len(content) > _PARALLEL_MAX_CHARS: + return f"{content[:_PARALLEL_MAX_CHARS]}\n[truncated at {_PARALLEL_MAX_CHARS} characters]" + return content + + def _normalize_url(url: str) -> str: """Canonical form for matching: case-fold scheme and host only, drop a trailing slash.""" parts = urlsplit(url.strip()) @@ -177,6 +226,10 @@ def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs i if not exa_key: return _not_configured_error("EXA_API_KEY") return ("exa", exa_key) + if provider == "parallel": + if not integrations.parallel_api_key: + return _not_configured_error("PARALLEL_API_KEY") + return ("parallel", integrations.parallel_api_key) if exa_key: return ("exa", exa_key) @@ -216,7 +269,7 @@ def _guarded_call[T]( # noqa: PLR0911 - each error class needs its own sanitize 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): + except (KeyError, IndexError, TypeError, ValueError): logger.exception("%s response shape unexpected", tool) return {"success": False, "error": f"{tool} returned an unexpected response. Try again"} except Exception: @@ -233,9 +286,16 @@ def _do_search(query: str) -> dict[str, Any]: if isinstance(resolved, dict): return resolved provider, api_key = resolved + if provider == "parallel" and len(query) > 5000: + return { + "success": False, + "error": "Parallel search queries must be at most 5000 characters", + } logger.info("web_search provider=%s query (len=%d): %s", provider, len(query), query[:120]) def fetch() -> str: + if provider == "parallel": + return _parallel_content(api_key, query) if provider == "exa": return _exa_content( api_key, @@ -248,7 +308,10 @@ def fetch() -> str: outcome = _guarded_call( "Web search", ( - "Web search rejected the query. Refine it " + "Parallel rejected the request. Check PARALLEL_API_KEY, account credits, " + "rate limits, and query length" + if provider == "parallel" + else "Web search rejected the query. Refine it " "(more specific, shorter, no unusual characters) and retry" ), fetch, @@ -318,7 +381,7 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]: @function_tool(timeout=330) async def web_search(ctx: RunContextWrapper, query: str) -> str: - """Real-time web search (Exa or Perplexity) — your primary research tool. + """Real-time web search (Exa, Perplexity, or Parallel) — your primary research tool. Use it liberally for anything that's not in your training data: @@ -354,6 +417,10 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str: full page text when a summary is not enough. With Perplexity you get a single synthesized cited answer. + 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. + **Good example queries** (each is a full sentence, names a version/product, and asks one concrete thing): diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index 463853d222..0a42d11c6c 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -31,6 +31,7 @@ "LLM_TIMEOUT", "PERPLEXITY_API_KEY", "EXA_API_KEY", + "PARALLEL_API_KEY", "STRIX_WEB_SEARCH_PROVIDER", # RuntimeSettings "STRIX_IMAGE", @@ -99,6 +100,30 @@ def test_read_json_overrides_maps_exa_and_provider(tmp_path: Path) -> None: } +def test_parallel_settings_persistence_and_env_precedence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "cli-config.json" + loader.apply_config_override(target) + monkeypatch.setenv("STRIX_WEB_SEARCH_PROVIDER", "parallel") + monkeypatch.setenv("PARALLEL_API_KEY", "parallel-file-key") + loader.persist_current() + assert target.stat().st_mode & 0o777 == 0o600 + + monkeypatch.delenv("PARALLEL_API_KEY") + monkeypatch.delenv("STRIX_WEB_SEARCH_PROVIDER") + loader.apply_config_override(target) + integrations = loader.load_settings().integrations + assert integrations.web_search_provider == "parallel" + assert integrations.parallel_api_key == "parallel-file-key" + assert "parallel-file-key" not in repr(integrations) + + monkeypatch.setenv("PARALLEL_API_KEY", "parallel-env-key") + loader.apply_config_override(target) + assert loader.load_settings().integrations.parallel_api_key == "parallel-env-key" + + def test_read_json_overrides_skips_keys_already_in_environ( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_web_search.py b/tests/test_web_search.py index 6aff26ba14..96ff9653f5 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -1,4 +1,4 @@ -"""Tests for web_search/web_get_contents provider selection and the Exa backend.""" +"""Tests for web_search/web_get_contents provider selection and backends.""" from __future__ import annotations @@ -34,6 +34,19 @@ def json(self) -> dict[str, Any]: return self._body +@pytest.fixture(autouse=True) +def _isolate_search_settings(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "PERPLEXITY_API_KEY", + "EXA_API_KEY", + "PARALLEL_API_KEY", + "STRIX_WEB_SEARCH_PROVIDER", + "STRIX_EXA_SEARCH_TYPE", + "STRIX_EXA_NUM_RESULTS", + ): + monkeypatch.delenv(key, raising=False) + + def test_auto_prefers_exa_when_both_keys_set() -> None: integrations = IntegrationSettings(PERPLEXITY_API_KEY="pk", EXA_API_KEY="ek") assert tool._resolve_provider(integrations) == ("exa", "ek") @@ -167,6 +180,9 @@ class _Settings: ({"STRIX_WEB_SEARCH_PROVIDER": "exa", "EXA_API_KEY": "ek"}, []), ({"STRIX_WEB_SEARCH_PROVIDER": "perplexity", "EXA_API_KEY": "ek"}, ["PERPLEXITY_API_KEY"]), ({"STRIX_WEB_SEARCH_PROVIDER": "perplexity", "PERPLEXITY_API_KEY": "pk"}, []), + ({"STRIX_WEB_SEARCH_PROVIDER": "parallel", "EXA_API_KEY": "ek"}, ["PARALLEL_API_KEY"]), + ({"STRIX_WEB_SEARCH_PROVIDER": "parallel", "PARALLEL_API_KEY": "key"}, []), + ({"PARALLEL_API_KEY": "key"}, ["EXA_API_KEY", "PERPLEXITY_API_KEY"]), ], ) def test_environment_validation_follows_provider_rules( @@ -414,3 +430,172 @@ class _Settings: "provider": "exa", "content": "answer", } + + +@pytest.mark.parametrize( + ("provider", "keys", "expected"), + [ + ("parallel", {"PARALLEL_API_KEY": "key", "EXA_API_KEY": "ek"}, ("parallel", "key")), + ("parallel", {"EXA_API_KEY": "ek"}, "PARALLEL_API_KEY"), + ("auto", {"PARALLEL_API_KEY": "key", "EXA_API_KEY": "ek"}, ("exa", "ek")), + ("auto", {"PARALLEL_API_KEY": "key", "PERPLEXITY_API_KEY": "pk"}, ("perplexity", "pk")), + ("auto", {"PARALLEL_API_KEY": "key"}, "EXA_API_KEY or PERPLEXITY_API_KEY"), + ], +) +def test_parallel_selection_is_opt_in( + provider: str, + keys: dict[str, str], + expected: tuple[str, str] | str, +) -> None: + integrations = IntegrationSettings.model_validate( + {"STRIX_WEB_SEARCH_PROVIDER": provider, **keys} + ) + result = tool._resolve_provider(integrations) + if isinstance(expected, str): + assert isinstance(result, dict) + assert result["success"] is False + assert expected in result["error"] + else: + assert result == expected + + +@pytest.fixture +def _parallel_settings(monkeypatch: pytest.MonkeyPatch) -> None: + class _Settings: + integrations = IntegrationSettings( + STRIX_WEB_SEARCH_PROVIDER="parallel", + PARALLEL_API_KEY="test-parallel-secret", + ) + + monkeypatch.setattr(tool, "load_settings", _Settings) + + +@pytest.mark.usefixtures("_parallel_settings") +def test_parallel_request_and_tool_response(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + captured.update(url=url, **kwargs) + return _FakeResponse( + { + "results": [ + { + "title": "Vendor advisory", + "url": "https://vendor.example/advisory", + "excerpts": [" Affected versions. ", "Fixed versions."], + }, + {"title": None, "url": "https://vendor.example/docs", "excerpts": []}, + {"title": "Missing URL"}, + "malformed entry", + ] + } + ) + + monkeypatch.setattr(requests, "post", fake_post) + query = "Find affected versions and mitigations in the vendor advisory. " * 4 + result = tool._do_search(query) + assert captured["url"] == "https://api.parallel.ai/v1/search" + assert captured["headers"] == { + "x-api-key": "test-parallel-secret", + "Content-Type": "application/json", + } + assert captured["json"] == { + "objective": query, + "search_queries": [query[:200]], + "mode": "fast", + "max_chars_total": 12000, + "advanced_settings": {"max_results": 5}, + } + assert captured["timeout"] == 300 + assert result == { + "success": True, + "query": query, + "provider": "parallel", + "content": "### Vendor advisory\nhttps://vendor.example/advisory\n\n" + "Affected versions.\n\nFixed versions.\n\n" + "### https://vendor.example/docs\nhttps://vendor.example/docs", + } + + +@pytest.mark.usefixtures("_parallel_settings") +@pytest.mark.parametrize( + ("body", "success", "text"), + [ + ({"results": []}, True, "No web search results found."), + ({}, False, "unexpected response"), + ({"results": "invalid"}, False, "unexpected response"), + ({"results": [{"title": "No URL"}]}, False, "unexpected response"), + ], +) +def test_parallel_empty_or_malformed_results( + monkeypatch: pytest.MonkeyPatch, + body: dict[str, Any], + success: bool, + text: str, +) -> None: + monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body)) + result = tool._do_search("vendor advisory") + assert result["success"] is success + assert text in result["content" if success else "error"] + + +def test_parallel_caps_output(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + requests, + "post", + lambda *_a, **_kw: _FakeResponse( + { + "results": [{"url": "https://vendor.example", "excerpts": ["x" * 20000]}], + } + ), + ) + result = tool._parallel_content("key", "vendor advisory") + assert result.startswith("### https://vendor.example\nhttps://vendor.example") + assert result.endswith("\n[truncated at 12000 characters]") + assert len(result) == 12000 + len("\n[truncated at 12000 characters]") + + +@pytest.mark.usefixtures("_parallel_settings") +@pytest.mark.parametrize("status", [401, 402, 422, 429, 503]) +def test_parallel_http_failures_are_sanitized(monkeypatch: pytest.MonkeyPatch, status: int) -> None: + def fail(*_args: Any, **_kwargs: Any) -> _FakeResponse: + response = requests.Response() + response.status_code = status + raise requests.HTTPError("test-parallel-secret: upstream body", response=response) + + monkeypatch.setattr(requests, "post", fail) + result = tool._do_search("vendor advisory") + assert result["success"] is False + assert "test-parallel-secret" not in result["error"] + assert "upstream body" not in result["error"] + assert ("PARALLEL_API_KEY" if status < 500 else "unavailable") in result["error"] + + +@pytest.mark.usefixtures("_parallel_settings") +@pytest.mark.parametrize( + ("exception", "message"), + [(requests.Timeout, "timed out"), (requests.ConnectionError, "network error")], +) +def test_parallel_network_failures( + monkeypatch: pytest.MonkeyPatch, + exception: type[requests.RequestException], + message: str, +) -> None: + def fail(*_args: Any, **_kwargs: Any) -> _FakeResponse: + raise exception("upstream error") + + monkeypatch.setattr(requests, "post", fail) + result = tool._do_search("vendor advisory") + assert result["success"] is False + assert message in result["error"] + + +@pytest.mark.usefixtures("_parallel_settings") +def test_parallel_rejects_oversized_objective_before_http(monkeypatch: pytest.MonkeyPatch) -> None: + def unexpected_call(*_args: Any, **_kwargs: Any) -> _FakeResponse: + pytest.fail("Invalid input must not make a request") + + monkeypatch.setattr(requests, "post", unexpected_call) + result = tool._do_search("x" * 5001) + assert result["success"] is False + assert "5000" in result["error"] From 584199901cec17719410cbde7f0c858a574353c0 Mon Sep 17 00:00:00 2001 From: Rowan Aldean <56801656+RowanAldean@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:43:44 +0100 Subject: [PATCH 2/4] fix(web_search): preserve Parallel source links when truncating --- docs/advanced/configuration.mdx | 4 ++- strix/tools/web_search/tool.py | 30 ++++++++++++++++----- tests/test_web_search.py | 46 +++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index fac557e382..38e79e5005 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -148,7 +148,9 @@ affecting the agents that do the actual testing. API key for [Parallel Search](https://docs.parallel.ai/search/search-quickstart). Set `STRIX_WEB_SEARCH_PROVIDER=parallel` to use it for the built-in `web_search` tool. Requests use `/v1/search` in `fast` mode, returning up to five source URLs with - relevant excerpts. Output is capped at 12,000 characters plus a truncation notice. + relevant excerpts. Excerpts are shortened to keep output within 12,000 characters + plus a truncation notice. Source titles and URLs are always preserved, even if + 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` diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py index 656701f358..6717dd4a38 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -136,6 +136,24 @@ def _exa_content(api_key: str, query: str, search_type: str, num_results: int) - _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)] + blocks.append(f"{header}\n\n{excerpt}" if excerpt else header) + if excerpt: + remaining -= len(excerpt) + 2 + truncated |= len(excerpt) < len(excerpts) + content = "\n\n".join(blocks) + if truncated: + content += "\n[excerpts truncated to preserve source links]" + return content + + def _parallel_content(api_key: str, query: str) -> str: with requests.post( "https://api.parallel.ai/v1/search", @@ -159,7 +177,7 @@ def _parallel_content(api_key: str, query: str) -> str: if not results: return "No web search results found." - blocks: list[str] = [] + blocks: list[tuple[str, str]] = [] for item in results[:5]: if not isinstance(item, dict): continue @@ -167,19 +185,17 @@ def _parallel_content(api_key: str, query: str) -> str: if not result.get("url"): continue url = str(result["url"]) - parts = [f"### {result.get('title') or url}\n{url}"] + header = f"### {result.get('title') or url}\n{url}" + parts: list[str] = [] excerpts: list[Any] | None = result.get("excerpts") if isinstance(excerpts, list): parts.extend( text.strip() for text in excerpts if isinstance(text, str) and text.strip() ) - blocks.append("\n\n".join(parts)) + blocks.append((header, "\n\n".join(parts))) if not blocks: raise ValueError("Parallel response has no usable results") - content = "\n\n".join(blocks) - if len(content) > _PARALLEL_MAX_CHARS: - return f"{content[:_PARALLEL_MAX_CHARS]}\n[truncated at {_PARALLEL_MAX_CHARS} characters]" - return content + return _parallel_result_content(blocks) def _normalize_url(url: str) -> str: diff --git a/tests/test_web_search.py b/tests/test_web_search.py index 96ff9653f5..20e2b21342 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -551,8 +551,50 @@ def test_parallel_caps_output(monkeypatch: pytest.MonkeyPatch) -> None: ) result = tool._parallel_content("key", "vendor advisory") assert result.startswith("### https://vendor.example\nhttps://vendor.example") - assert result.endswith("\n[truncated at 12000 characters]") - assert len(result) == 12000 + len("\n[truncated at 12000 characters]") + notice = "\n[excerpts truncated to preserve source links]" + assert result.endswith(notice) + assert len(result) == 12000 + len(notice) + + +@pytest.mark.parametrize("first_excerpt_size", [11920, 20000]) +def test_parallel_truncation_keeps_all_source_headers( + monkeypatch: pytest.MonkeyPatch, + first_excerpt_size: int, +) -> None: + results = [ + { + "title": f"Vendor advisory {index}", + "url": f"https://vendor.example/advisory/{index}/" + "a" * 100, + "excerpts": ["x" * (first_excerpt_size if index == 0 else 2000)], + } + for index in range(5) + ] + monkeypatch.setattr( + requests, + "post", + lambda *_a, **_kw: _FakeResponse({"results": results}), + ) + content = tool._parallel_content("key", "vendor advisories") + for result in results: + assert f"### {result['title']}\n{result['url']}" in content + notice = "\n[excerpts truncated to preserve source links]" + assert content.endswith(notice) + assert len(content) <= 12000 + len(notice) + + +@pytest.mark.parametrize("spare_chars", [-1, 0, 1, 2, 3]) +def test_parallel_keeps_headers_when_no_excerpt_fits( + monkeypatch: pytest.MonkeyPatch, + spare_chars: int, +) -> 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) + spare_chars) + content = tool._parallel_result_content([(header, "excerpt") for header in headers]) + assert all(header in content for header in headers) + notice = "\n[excerpts truncated to preserve source links]" + assert content.endswith(notice) + assert len(content) <= max(len(header_text), tool._PARALLEL_MAX_CHARS) + len(notice) @pytest.mark.usefixtures("_parallel_settings") From 90acb01e883de8e72bac78d90652f485010dd722 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 18:37:31 +0000 Subject: [PATCH 3/4] 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 38e79e5005..e964c87f97 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 466d91af63..d04ff0be46 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 35314598b6..122f234fab 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 e7ee0634d7..fe293c439a 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 6717dd4a38..daee9874f0 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -137,17 +137,18 @@ def _exa_content(api_key: str, query: str, search_type: str, num_results: int) - 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 fetch() -> str: } -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 20e2b21342..5a0efb0b9e 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -300,12 +300,67 @@ class _Settings: 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 @@ class _Settings: 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 boom(*_args: Any, **_kwargs: Any) -> None: 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 fake_post(url: str, **kwargs: Any) -> _FakeResponse: ) 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 fake_post(url: str, **kwargs: Any) -> _FakeResponse: } +@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, From e36603632e6148a36a4de562e43ba0c926ebb2bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 18:50:59 +0000 Subject: [PATCH 4/4] fix(web_search): cap and rebalance Parallel content Co-authored-by: Rowan Aldean --- strix/tools/web_search/tool.py | 47 ++++++++++--- tests/test_web_search.py | 119 ++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 11 deletions(-) diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py index daee9874f0..bc477d630c 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -139,16 +139,38 @@ def _exa_content(api_key: str, query: str, search_type: str, num_results: int) - def _parallel_result_content(results: list[tuple[str, str]]) -> str: header_chars = sum(len(header) for header, _ in results) + 2 * (len(results) - 1) remaining = max(0, _PARALLEL_MAX_CHARS - header_chars) + excerpts = [excerpt.rstrip() for _, excerpt in results] + allocations = [0] * len(results) + nonempty = [index for index, excerpt in enumerate(excerpts) if excerpt] + full_cost = sum(len(excerpts[index]) + 2 for index in nonempty) + + if full_cost <= remaining: + for index in nonempty: + allocations[index] = len(excerpts[index]) + else: + selected = nonempty[: min(len(nonempty), remaining // 3)] + content_budget = remaining - 2 * len(selected) + pending = set(selected) + while pending: + share = content_budget // len(pending) + satisfied = [index for index in pending if len(excerpts[index]) <= share] + if not satisfied: + base, extra = divmod(content_budget, len(pending)) + for offset, index in enumerate(sorted(pending)): + allocations[index] = base + (offset < extra) + break + for index in satisfied: + allocations[index] = len(excerpts[index]) + content_budget -= allocations[index] + pending.remove(index) + blocks: list[str] = [] - truncated = False - for index, (header, excerpts) in enumerate(results): - share = remaining // (len(results) - index) - excerpt_slice = excerpts[: max(0, share - 2)] - excerpt = excerpt_slice.rstrip() + for index, (header, _) in enumerate(results): + excerpt = excerpts[index][: allocations[index]].rstrip() blocks.append(f"{header}\n\n{excerpt}" if excerpt else header) - if excerpt: - remaining -= len(excerpt) + 2 - truncated |= len(excerpt_slice) < len(excerpts) + truncated = any( + allocation < len(excerpt) for allocation, excerpt in zip(allocations, excerpts, strict=True) + ) content = "\n\n".join(blocks) if truncated: content += "\n[excerpts truncated to preserve source links]" @@ -234,7 +256,10 @@ def _exa_page_text(api_key: str, urls: list[str]) -> tuple[str, set[str]]: 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}} + payload = { + "urls": urls, + "advanced_settings": {"full_content": {"max_chars_per_result": _EXA_PAGE_MAX_CHARS}}, + } with requests.post( "https://api.parallel.ai/v1/extract", headers=headers, @@ -270,6 +295,10 @@ def _parallel_page_text(api_key: str, urls: list[str]) -> tuple[str, set[str]]: ) if not content: continue + if len(content) > _EXA_PAGE_MAX_CHARS: + content = ( + f"{content[:_EXA_PAGE_MAX_CHARS]}\n[truncated at {_EXA_PAGE_MAX_CHARS} characters]" + ) 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}") diff --git a/tests/test_web_search.py b/tests/test_web_search.py index 5a0efb0b9e..5f65c593a0 100644 --- a/tests/test_web_search.py +++ b/tests/test_web_search.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import pytest import requests @@ -513,7 +513,7 @@ def fake_post(url: str, **kwargs: Any) -> _FakeResponse: } assert captured["json"] == { "urls": ["https://vendor.example/advisory", "https://vendor.example/docs"], - "advanced_settings": {"full_content": True}, + "advanced_settings": {"full_content": {"max_chars_per_result": tool._EXA_PAGE_MAX_CHARS}}, } assert captured["timeout"] == 300 assert content == ( @@ -526,6 +526,107 @@ def fake_post(url: str, **kwargs: Any) -> _FakeResponse: } +@pytest.mark.parametrize( + ("body", "exception", "message"), + [ + (cast("Any", []), TypeError, "must be an object"), + ({"results": []}, ValueError, "no page contents"), + ( + { + "results": [ + { + "url": "https://empty.example", + "full_content": " ", + "excerpts": [" "], + } + ] + }, + ValueError, + "no page contents", + ), + ], +) +def test_parallel_page_text_rejects_empty_or_contentless_responses( + monkeypatch: pytest.MonkeyPatch, + body: Any, + exception: type[Exception], + message: str, +) -> None: + monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body)) + + with pytest.raises(exception, match=message): + tool._parallel_page_text("parallel-key", ["https://empty.example"]) + + +def test_parallel_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://long.example", + "full_content": body, + "excerpts": [], + } + ] + } + ), + ) + + content, fetched = tool._parallel_page_text("parallel-key", ["https://long.example"]) + + assert content.count("A") == tool._EXA_PAGE_MAX_CHARS + assert f"[truncated at {tool._EXA_PAGE_MAX_CHARS} characters]" in content + assert fetched == {"https://long.example"} + + +def test_do_get_contents_reports_parallel_extract_partial_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _Settings: + integrations = IntegrationSettings( + PARALLEL_API_KEY="parallel-key", + STRIX_WEB_SEARCH_PROVIDER="parallel", + ) + + monkeypatch.setattr(tool, "load_settings", _Settings) + monkeypatch.setattr( + requests, + "post", + lambda *_a, **_kw: _FakeResponse( + { + "results": [ + { + "url": "https://ok.example/", + "full_content": "Page body.", + "excerpts": [], + } + ], + "errors": [ + { + "url": "https://blocked.example", + "error_type": "fetch_error", + "http_status_code": 403, + "content": None, + } + ], + } + ), + ) + + result = tool._do_get_contents(["https://ok.example", "https://blocked.example"]) + + assert result["success"] is True + assert result["provider"] == "parallel" + assert result["urls"] == ["https://ok.example"] + assert result["failed_urls"] == ["https://blocked.example"] + assert "Parallel Extract" in result["warning"] + assert "1 of 2" in result["warning"] + + def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch) -> None: class _Settings: integrations = IntegrationSettings(EXA_API_KEY="ek") @@ -744,6 +845,20 @@ def test_parallel_spreads_excerpt_budget_and_strips_trailing_whitespace( assert content.endswith("[excerpts truncated to preserve source links]") +def test_parallel_redistributes_unused_excerpt_budget() -> None: + results = [ + ("### Long\nhttps://long.example", "a" * 3500), + *[(f"### Short {index}\nhttps://short{index}.example", "b" * 100) for index in range(4)], + ] + + content = tool._parallel_result_content(results) + + assert "a" * 3500 in content + assert content.count("b" * 100) == 4 + assert "[excerpts truncated" not in content + assert len(content) <= tool._PARALLEL_MAX_CHARS + + def test_parallel_strips_trailing_excerpt_whitespace() -> None: content = tool._parallel_result_content( [("### Result\nhttps://result.example", "Complete excerpt.\n\n")]