This commit is contained in:
Rowan Aldean 2026-11-05 17:02:00 -03:00 • committed by GitHub
commit 8d3950167e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 811 additions and 27 deletions

View file

@ -137,11 +137,29 @@ 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">
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.
</ParamField>
<ParamField path="PARALLEL_API_KEY" type="string">
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. 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. 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"
```
</ParamField>
<ParamField path="STRIX_EXA_SEARCH_TYPE" default="auto" type="string">
@ -235,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

@ -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",
@ -216,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

@ -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"]
@ -34,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()
@ -111,6 +113,10 @@ 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("• ", 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")
error_text.append("STRIX_REASONING_EFFORT", style="bold cyan")
@ -137,6 +143,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",

View file

@ -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,98 @@ 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_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] = []
for index, (header, _) in enumerate(results):
excerpt = excerpts[index][: allocations[index]].rstrip()
blocks.append(f"{header}\n\n{excerpt}" if excerpt else header)
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]"
return content
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[tuple[str, str]] = []
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_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):
parts.extend(
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)
def _normalize_url(url: str) -> str:
"""Canonical form for matching: case-fold scheme and host only, drop a trailing slash."""
parts = urlsplit(url.strip())
@ -161,6 +253,61 @@ 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": {"max_chars_per_result": _EXA_PAGE_MAX_CHARS}},
}
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
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}")
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]:
@ -177,6 +324,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 +367,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:
@ -225,7 +376,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
@ -233,9 +385,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 +407,10 @@ def _do_search(query: str) -> dict[str, Any]:
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,
@ -263,7 +425,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"}
@ -274,9 +438,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 {
@ -288,11 +449,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
@ -301,7 +477,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:
@ -310,7 +486,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
@ -318,7 +495,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 +531,11 @@ 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`` 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):
@ -385,7 +567,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
@ -396,9 +578,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

@ -31,6 +31,7 @@ _LLM_ENV_KEYS = [
"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:

View file

@ -1,8 +1,8 @@
"""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
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import pytest
import requests
@ -34,6 +34,19 @@ class _FakeResponse:
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 @@ def test_do_search_reports_empty_exa_results_as_unexpected(
({"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(
@ -284,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",
)
@ -300,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:
@ -399,6 +471,162 @@ 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": {"max_chars_per_result": tool._EXA_PAGE_MAX_CHARS}},
}
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",
}
@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")
@ -414,3 +642,313 @@ def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch)
"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
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:
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).strip()
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")
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"),
[
({"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")
notice = "\n[excerpts truncated to preserve source links]"
assert result.endswith(notice)
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_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")]
)
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,
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")
@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"]