mirror of
https://github.com/usestrix/strix.git
synced 2026-11-14 09:01:25 +00:00
fix(web_search): address Parallel provider review findings
Co-authored-by: Rowan Aldean <RowanAldean@users.noreply.github.com>
This commit is contained in:
parent
584199901c
commit
90acb01e88
6 changed files with 310 additions and 37 deletions
|
|
@ -137,7 +137,7 @@ affecting the agents that do the actual testing.
|
|||
</ParamField>
|
||||
|
||||
<ParamField path="EXA_API_KEY" type="string">
|
||||
API key for Exa. Enables real-time web search through the Exa `/search` endpoint. Exa also powers the `web_get_contents` tool, which fetches the full text of a page through the Exa `/contents` endpoint. This is the preferred web search provider.
|
||||
API key for Exa. Enables real-time web search through the Exa `/search` endpoint. Exa powers the `web_get_contents` tool through the Exa `/contents` endpoint unless Parallel is pinned. This is the preferred web search provider.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_WEB_SEARCH_PROVIDER" default="auto" type="string">
|
||||
|
|
@ -153,14 +153,14 @@ affecting the agents that do the actual testing.
|
|||
those headers alone exceed the budget.
|
||||
Queries may contain up to 5,000 characters; the full query is the search objective,
|
||||
with its first 200 characters also supplied as a search query.
|
||||
This uses the authenticated API, not the free Search MCP. `web_get_contents`
|
||||
remains Exa-only and requires `EXA_API_KEY`.
|
||||
</ParamField>
|
||||
This uses the authenticated API, not the free Search MCP. When Parallel is pinned,
|
||||
`web_get_contents` uses the Parallel `/v1/extract` endpoint with the same key.
|
||||
|
||||
```bash
|
||||
export STRIX_WEB_SEARCH_PROVIDER="parallel"
|
||||
export PARALLEL_API_KEY="your-parallel-api-key"
|
||||
```
|
||||
```bash
|
||||
export STRIX_WEB_SEARCH_PROVIDER="parallel"
|
||||
export PARALLEL_API_KEY="your-parallel-api-key"
|
||||
```
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_EXA_SEARCH_TYPE" default="auto" type="string">
|
||||
Exa search mode: `auto`, `fast`, `instant`, `deep-lite`, `deep`, or `deep-reasoning`. Lower modes return results faster. Higher modes plan across more steps and take more time. This setting applies only to the Exa provider.
|
||||
|
|
@ -253,7 +253,7 @@ strix --target ./app --config /path/to/config.json
|
|||
export STRIX_LLM="openrouter/z-ai/glm-5.3"
|
||||
export LLM_API_KEY="sk-..."
|
||||
|
||||
# Optional: Enable web search (Exa preferred, Perplexity supported)
|
||||
# Optional: Enable web search (Exa preferred; Perplexity and opt-in Parallel supported)
|
||||
export EXA_API_KEY="..."
|
||||
export PERPLEXITY_API_KEY="pplx-..."
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue