Skip to content

feat(web-search): add WebSearchTask with pluggable search providers - #850

Open
sroussey wants to merge 16 commits into
mainfrom
claude/websearch-task-providers-8aik77
Open

feat(web-search): add WebSearchTask with pluggable search providers#850
sroussey wants to merge 16 commits into
mainfrom
claude/websearch-task-providers-8aik77

Conversation

@sroussey

@sroussey sroussey commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Adds @workglow/web-search — one WebSearchTask serving both plain search APIs and model-grounded search behind a single normalized output shape, routed by provider capability. Seven providers ship.

Design spec and implementation plan live in the prd repo on the same branch name (docs/superpowers/specs/2026-08-22-web-search-task-design.md).

Why one task for both families

The obvious split — "search APIs" vs "grounded search" — isn't real. Tavily returns a synthesized answer when asked; Exa returns full page content; Anthropic's web_search returns an answer plus citations that are structurally {title, url, snippet}. So this is one shape whose fields different providers can or cannot populate — a capability matrix, not a discriminated union.

results is always present (for a grounded provider, those are its citations). answer is populated only when the caller asks for one, uniformly across providers.

Server-side only

Every commercial search API authenticates with a request header, which forces a CORS preflight none of them answer. Google's Custom Search JSON API is the one exception that genuinely permits cross-origin XHR — and it is closed to new customers and sunsets 2027-01-01. Independently, a browser-executed search would put the API key where any visitor can read it.

The browser entry therefore registers the task (so a builder UI can render the node and validate a graph) and no providers.

Capability routing

provider is required with no default, mirroring response_type on FetchUrlTask — which provider serves a request decides its cost, rate limit and quality. "auto" opts into routing over the requested options; the provider that ran is always reported on the provider output port. A pinned provider that cannot honor an option throws rather than silently rerouting.

domainFilter is three-valued: "native", "query-operator" (the task rewrites the query with site:), or false. Date filtering is never emulated — post-filtering by publishedDate breaks maxResults and drops every result whose date the provider omitted, so dateFilter: false means such a request is refused.

excludeDomainFilter is optional and defaults to domainFilter. It exists because OpenAI's web_search takes filters.allowed_domains and the installed SDK models no blocked equivalent. Folded into one field, that provider must either under-declare (losing working include filtering) or over-declare — and over-declaring is the failure this record exists to prevent: "auto" would route an excludeDomains request to a provider that cannot honor it, and the adapter would throw after selection instead of the task refusing before it.

Providers

provider auth answer content domain filter date filter
brave X-Subscription-Token no no via site: yes
tavily bearer yes yes native yes
searxng none (self-hosted) no no via site: no
anthropic vendor SDK yes no native (either/or) no
openai vendor SDK yes no native, include only no
openrouter vendor SDK yes yes native no
gemini vendor SDK yes no none yes

The grounded four are not variations on one shape. Gemini is the exact mirror of Anthropic, and the SDK comments say why: GoogleSearch.excludeDomains is "not supported in Gemini API" (Vertex only), while timeRangeFilter is "not supported in Vertex AI". Every capability here was read from the installed SDK typings, not the vendor docs — they disagree, and the typings are what the code compiles against.

HTTP adapters (Brave, Tavily, SearXNG) execute by owning a FetchUrlTask, inheriting credential resolution via credential_key, SafeFetch's redirect/SSRF checks, retry/backoff, per-attempt timeouts and the response cache.

They do not inherit the queue's rate limiter, and cannot: FetchUrlTask refuses credential_key on the queued path because a queued payload is persisted to durable storage, so every keyed provider runs inline. Bounding a MapTask fan-out remains the caller's responsibility. (This corrects an earlier overclaim in this PR, caught in review.)

The grounded adapters ship as ./web-search subpaths on their vendor packages, so @workglow/web-search has no dependency on @workglow/ai. They register explicitly — importing the subpath registers nothing, matching how ./ai exports registerAnthropic* rather than self-registering.

Entitlements

The instance declaration is narrowed to the pinned provider's own destination. fetchUrlEntitlementsFor does not scope network:http — it decides whether network:private is required: absent for a public origin, scoped to the host for a private one, unscoped (fail-closed) when the destination is unknown.

That matters because SearXNG is self-hosted and routinely sits on a private address. So "auto" fails closed — routing happens at run time and may land on a private instance — while a pinned public provider is correspondingly narrower than the base, and an SDK-backed provider needs no private access at all.

Vendor traps handled and tested

  • Anthropic's web_search_tool_result carries a list on success and an error object on failure, at HTTP 200 — reading it unbranched records a quota failure as a search that found nothing.
  • An Anthropic server-tool turn can stop with pause_turn, which must be resumed or the answer truncates silently.
  • Gemini rejects a one-sided time interval, so a half-open dateRange has its open end filled rather than being refused.
  • OpenAI and Gemini both cite one source at many spans, so results are de-duplicated into a source list rather than a citation list.

Tests

114 tests. SearXNG needs no key and has no quota, so it is the only provider whose integration test can run unmocked (.integration.test.ts, excluded from the default tier, skipped unless WEB_SEARCH_SEARXNG_URL is set). The rest are fixture-driven.

Not in scope

No streaming (a grounded answer could stream, but results cannot arrive incrementally usefully). No re-ranking, deduplication across providers, or multi-provider fan-out — one query, one provider, one response.

sroussey and others added 10 commits August 22, 2026 06:39
Adds the package to the dependency graph and key-packages list, and
corrects the vendor-subpath paragraph: providers/* is no longer only
./ai and ./ai-runtime now that anthropic ships ./web-search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4wM782LCRMPFPKeGzrzu9
Comment thread packages/web-search/src/providers/SearxngWebSearchProvider.ts Fixed
Comment thread packages/web-search/src/queryOperators.ts Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a new @workglow/web-search package that standardizes web search behind a provider interface with capability-based routing, and adds an Anthropic grounded web-search provider entrypoint in @workglow/anthropic.

Changes:

  • Adds @workglow/web-search with WebSearchTask, provider registry, capability checks, and built-in HTTP providers (Brave, Tavily, SearXNG).
  • Adds @workglow/anthropic/web-search entrypoint implementing the IWebSearchProvider contract.
  • Adds tests and wiring (exports/build scripts/tsconfig references) to support the new package and provider.

Reviewed changes

Copilot reviewed 30 out of 31 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
providers/anthropic/tsconfig.json Adds TS project reference to the new packages/web-search.
providers/anthropic/src/web-search/AnthropicWebSearchProvider.ts Implements Anthropic grounded web-search provider via vendor SDK.
providers/anthropic/src/web-search.ts Adds ./web-search entrypoint and registration helper for the Anthropic provider.
providers/anthropic/src/tests/AnthropicWebSearchProvider.test.ts Unit tests for Anthropic web-search provider behavior (tool type, mapping, pause_turn, domain lists).
providers/anthropic/package.json Exports ./web-search, builds it, and adds dependency on @workglow/web-search.
packages/web-search/tsconfig.json Adds composite TS config for the new @workglow/web-search package.
packages/web-search/src/WebSearchTask.ts Implements the WebSearchTask with provider routing, capability enforcement, and request adaptation.
packages/web-search/src/WebSearchProviderRegistry.ts Adds a process-wide provider registry with routing and helpful error messages.
packages/web-search/src/queryOperators.ts Implements site: query rewriting for providers that only support domain filtering via query operators.
packages/web-search/src/providers/TavilyWebSearchProvider.ts Adds Tavily HTTP adapter with capability declaration and request/response mapping.
packages/web-search/src/providers/SearxngWebSearchProvider.ts Adds SearXNG adapter with base URL validation and result normalization.
packages/web-search/src/providers/httpSearch.ts Shared helper to execute provider HTTP calls via an owned FetchUrlTask.
packages/web-search/src/providers/BraveWebSearchProvider.ts Adds Brave HTTP adapter including date-range mapping to freshness.
packages/web-search/src/node.ts Node entrypoint that registers the task and built-in providers on import.
packages/web-search/src/IWebSearchProvider.ts Defines provider interface, request/response types, and capability model.
packages/web-search/src/common.ts Common exports plus registration helpers for task and built-in providers.
packages/web-search/src/capabilityCheck.ts Implements capability/request gap detection used for routing and pinned-provider validation.
packages/web-search/src/browser.ts Browser entrypoint that registers only the task (no providers).
packages/web-search/src/tests/WebSearchTask.test.ts Tests routing behavior, pinned-provider validation, query rewriting, and maxResults clamping.
packages/web-search/src/tests/WebSearchProviderRegistry.test.ts Tests registry semantics (register/get/route/require/clear) and error messages.
packages/web-search/src/tests/TavilyWebSearchProvider.test.ts Tests Tavily adapter mapping, auth scheme, and option behavior.
packages/web-search/src/tests/SearxngWebSearchProvider.test.ts Tests SearXNG adapter URL construction, credential suppression, normalization, and truncation.
packages/web-search/src/tests/SearxngWebSearchProvider.integration.test.ts Optional live integration test for SearXNG behind env var gating.
packages/web-search/src/tests/queryOperators.test.ts Tests site: query operator rewriting and normalization.
packages/web-search/src/tests/entries.test.ts Tests that entrypoints register tasks/providers as intended.
packages/web-search/src/tests/capabilityCheck.test.ts Tests capability gap detection across request options.
packages/web-search/src/tests/BraveWebSearchProvider.test.ts Tests Brave adapter mapping, auth header scheme, and freshness date mapping.
packages/web-search/README.md Documents server-only behavior, provider matrix, and usage patterns.
packages/web-search/package.json Defines new package exports/build/test scripts and peer deps.
bun.lock Adds workspace entry and dependency wiring for @workglow/web-search.
.claude/CLAUDE.md Updates repo architecture and adds @workglow/web-search documentation section.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/web-search/src/providers/httpSearch.ts Outdated
Comment thread .claude/CLAUDE.md Outdated
Comment thread providers/anthropic/src/web-search/AnthropicWebSearchProvider.ts
CodeQL flagged two high-severity polynomial-ReDoS alerts. `/\/+$/` is
quadratic on a string holding a long run of slashes that is not at the
end: the engine starts `\/+` at every slash position, consumes the whole
run, fails `$`, and restarts one character along. Both call sites take
untrusted text — a search domain from task input, and a configured base
URL.

Measured on a 200k-slash input: 49.7s before, under 1ms after. The
regression test asserts a 1000ms budget, so only a reintroduction of the
pattern can trip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4wM782LCRMPFPKeGzrzu9
@sroussey sroussey changed the title feat(web-search): add provider interface and capability check feat(web-search): add WebSearchTask with pluggable search providers Aug 22, 2026
sroussey and others added 4 commits August 22, 2026 08:02
Two review findings.

Owning a FetchUrlTask does not inherit the job queue's rate limiter. The
task refuses credential_key on the queued path because a queued payload
is persisted to durable storage, so every keyed provider runs inline and
inline fetches are not rate limited. The JSDoc and CLAUDE.md said
otherwise; bounding a fan-out is the caller's job and now says so.

The Anthropic provider returned an answer whether or not the caller
asked, while Tavily returns one only on request — so `answer` meant
different things depending on which provider routing picked. It is now
gated on includeAnswer in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4wM782LCRMPFPKeGzrzu9
Both tsconfigs compiled src/**/__tests__ into dist, and both packages
publish files: ["dist"], so the new tests shipped as .d.ts to consumers.
Adds the exclude packages/task-graph already uses for its co-located
tests. Vitest collects via its own project config, so the tests still run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4wM782LCRMPFPKeGzrzu9
The new web-search test made providers/anthropic a workspace that holds
tests, and it had no `test` script. Turbo reports such a workspace's test
task successful while running nothing, so the suite would have been
skipped in CI while reporting green — which is what testDiscovery's
"every workspace that holds tests has a test script" guard caught.

Adds the documented per-package script. `--project anthropic` now
collects and passes the 9 tests it was silently skipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4wM782LCRMPFPKeGzrzu9
Three new ./web-search subpaths, each implementing IWebSearchProvider
against its vendor SDK, so @workglow/web-search still depends on nothing
AI-related.

Adds an optional excludeDomainFilter capability, defaulting to
domainFilter. OpenAI's web_search takes filters.allowed_domains and the
installed SDK models no blocked equivalent, so a single field would force
that provider to under-declare (losing working include filtering) or
over-declare. Over-declaring is the failure the record exists to prevent:
"auto" would route an excludeDomains request to a provider that cannot
honor it, and the adapter would throw after selection instead of the task
refusing before it.

The grounded providers are not variations on one shape. Anthropic filters
by domain, not date. Gemini is the mirror image — the SDK documents
excludeDomains as Vertex-only and timeRangeFilter as not supported on
Vertex — so on the Gemini API path it is date-only, and it fills the open
end of a half-open range because the API rejects a one-sided interval.
OpenAI is inclusion-only. OpenRouter does both.

Each capability was read from the installed SDK typings rather than the
vendor docs, which disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4wM782LCRMPFPKeGzrzu9
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 61.14% 41049 / 67131
🔵 Statements 60.61% 43124 / 71149
🔵 Functions 61.76% 7897 / 12786
🔵 Branches 49.73% 21387 / 43004
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/web-search/src/IWebSearchProvider.ts 100% 100% 100% 100%
packages/web-search/src/WebSearchProviderRegistry.ts 95.65% 87.5% 87.5% 95% 79
packages/web-search/src/WebSearchTask.ts 100% 95% 100% 100%
packages/web-search/src/capabilityCheck.ts 100% 100% 100% 100%
packages/web-search/src/common.ts 100% 85.71% 100% 100%
packages/web-search/src/queryOperators.ts 100% 100% 100% 100%
packages/web-search/src/urlText.ts 100% 100% 100% 100%
packages/web-search/src/providers/BraveWebSearchProvider.ts 100% 84.21% 100% 100%
packages/web-search/src/providers/SearxngWebSearchProvider.ts 95% 81.25% 100% 94.73% 50-52
packages/web-search/src/providers/TavilyWebSearchProvider.ts 86.95% 76% 100% 100% 41, 46, 47
packages/web-search/src/providers/httpSearch.ts 100% 100% 100% 100%
providers/anthropic/src/web-search/AnthropicWebSearchProvider.ts 90.19% 77.08% 100% 97.77% 64, 75, 124-126, 146, 155
providers/google-gemini/src/web-search/GeminiWebSearchProvider.ts 97.22% 81.08% 100% 100% 50
providers/openai/src/web-search/OpenAiWebSearchProvider.ts 96.96% 80.64% 100% 100% 93
providers/openrouter/src/web-search/OpenRouterWebSearchProvider.ts 96.77% 81.25% 100% 100% 96
Generated in workflow #3260 for commit d030926 by the Vitest Coverage Report Action

…osed

The coverage report flagged the instance entitlements() as uncovered, and
writing the test showed the branch was dead: an enforcer asks before the
run, and runInputData is populated from defaults only once
resetInputData() has run, so every instance returned the static set.

Correcting it also corrected the comment. fetchUrlEntitlementsFor does
not scope network:http; it decides whether network:private is required —
absent for a public origin, scoped to the host for a private one, and
unscoped when the destination is unknown. That matters because SearXNG is
self-hosted and routinely sits on a private address.

So "auto" now fails closed rather than returning the bare base: routing
happens at run time and may land on a privately-hosted instance, which the
base would not have required a grant for. A pinned public provider is
correspondingly narrower than before, and an SDK-backed provider needs no
private access at all.

Also covers the two other gaps the report named: the no-JSON-body throw
and Brave's open-ended freshness range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4wM782LCRMPFPKeGzrzu9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants