Skip to content

[front] feat: provider-native web search behind a feature flag - #32242

Draft
Nils-Fedrigo wants to merge 1 commit into
mainfrom
nils/provider-native-web-search-400652
Draft

[front] feat: provider-native web search behind a feature flag#32242
Nils-Fedrigo wants to merge 1 commit into
mainfrom
nils/provider-native-web-search-400652

Conversation

@Nils-Fedrigo

@Nils-Fedrigo Nils-Fedrigo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Description

Adds the provider_native_web_search feature flag (dust_only). When it is on and the request's host is Anthropic or OpenAI Responses, Dust's own websearch tool is dropped from the request and the provider's server-side web search tool is sent instead. The point is to measure the provider path against ours on real traffic — latency, answer quality, cost.

Scope is deliberately narrow:

  • Search only. webbrowser (Firecrawl/Exa) is untouched on both providers. Anthropic has a native web_fetch and OpenAI Responses has no fetch equivalent, so swapping browse too would make agent capabilities diverge by provider and would lose screenshots, link extraction, summarization and the tool-output file offload.
  • Gated on host, not provider id. isNativeWebSearchEnabled keys on ANTHROPIC_HOST / OPENAI_RESPONSES_HOST, which excludes AGENT_PLATFORM_HOST (Vertex) even though it shares Anthropic's provider id and input converter — server-tool support there is unverified, and the regional endpoints exist for workspaces that don't want queries leaving the region. Adding a host is the single switch to flip once verified.
  • No-op for every other provider by construction; no-op everywhere with the flag off.

How it is wired, mirroring the existing tool-search precedent end to end: run_model.ts derives the flag and filters the tool out of the action list, then it rides LLMStreamParametersbuildConfigInputConfig.nativeWebSearchEnabled → the per-SDK input converters, which prepend the server tool.

Two consequences worth calling out for review:

  • Citations become inline markdown links. Dust's :cite[REF] chips can't work here — refs are keyed on a persisted AgentMCPAction row and a server-side search produces none. So provider citation metadata is rendered into the answer text as ([title](url)). On Anthropic the citations_delta arrives right after the span it cites, so the link is emitted as a delta and appended to the accumulator, keeping the final persisted text byte-identical to what streamed. OpenAI reports offsets into the finished text, so links are spliced by end_index at output_item.done (the authoritative, persisted event) with matching deltas for the live view. Consecutive repeats of the same source are suppressed. citationMetaPrompt stops firing for a web-search-only agent, since it would then have no real refs to cite.
  • No action card. No MCP action row is created, so there is no "Searching the web" step, no citation chips, no source list, and no message-breakdown entry. Per-workspace search-provider choice (webSearchProvider metadata, the set_web_providers poke plugin), the global_disable_firecrawl kill switch and getWebsearchNumResults budgeting also don't apply. Query volume, result counts and provider-side errors are covered by logs plus a llm_native_web_search.requests counter instead.

Replay needed real work: Anthropic's server_tool_use + web_search_tool_result blocks were previously dropped, and the tool-search passthrough parser actively rejected a web_search block and logged it as unparseable — which would have broken interleaved thinking signatures. Both families now have their own zod schemas and replay rules behind a parseAnthropicServerToolBlock dispatcher, with a matching sanitizer that strips a family's blocks when its tool is absent from the request (auxiliary calls such as title generation). OpenAI's web_search_call items are replayed for the same reason the tool-search items are: the API rejects a reasoning item whose following item was dropped, and a search call routinely sits between a reasoning item and the message.

Refs 400652.

Tests

Unit tests only — every surface touched is a pure converter or predicate. npx tsgo --noEmit is clean; 438 tests pass with 0 failures.

New coverage: server-tool ordering and the force-call/never-defer rules on both providers; the passthrough schemas and replay sanitation (including that the common path returns its input array by identity, so prompt-cache bytes are unchanged); the name-aware server_tool_use branch, with an explicit guard against it swallowing tool search; citation splicing on both the streaming and non-streaming paths; isDustWebsearchTool across both internal servers that mount websearch, and that it leaves webbrowser and same-named third-party tools alone; and the host gate's no-op guarantee for every other host.

Not yet verified, and the one thing to check before this is opened for review: that tool_choice: {type:"none"} actually suppresses the server tool. See Risk.

Risk

Low with the flag off — the tool array, prompt and token-count estimate are all unchanged, and the two shared passthrough parsers keep their existing behavior (they only stopped owning their own "unparseable" warning, which moved to the dispatcher).

Three things to know before rolling past dust_only:

  1. tool_choice: "none" is an unverified assumption. The native tool is deliberately sent on every step, including the forced final generation, so the tools prefix stays byte-stable and the prompt cache is never invalidated mid-run. Suppression on the last step therefore rests entirely on tool_choice — documented as "the model will not be allowed to use tools" with no server-tool carve-out, and server tools are invoked through the same mechanism (the model emits a server_tool_use block). Needs a live check: drive a run to its last step with a search-hungry prompt and confirm no server_tool_use block appears. If one does, the fallback is one line — drop the tool when disableToolUse is set — because the replay sanitizer is already keyed on whether the tool is in the request. Dropping it unconditionally instead would cost a cache miss on every last step, and a cache breakpoint can't rescue that: the tools array sits ahead of the system prompt and messages in the cached prefix.
  2. Search fees are invisible to cost attribution. Both providers bill server-side web search on top of tokens. Anthropic reports the count under usage.server_tool_use — on the very object the usage converter already receives — but TokenUsageContent has no slot for it, so nothing reaches RunUsageType or credit attribution. This is a cost shift rather than new cost (Dust's websearch already pays Firecrawl/Exa and is metered via toolCostCategory: "basic"), but it moves from a metered path to an unmetered one. Mitigated for now by max_uses: 8 on the tool plus per-search logging and a StatsD counter; a serverToolRequests field on TokenUsageContent should land before broader rollout.
  3. Data egress. Model-generated queries, derived from conversation content, go to the provider's search infrastructure and its upstream partners rather than to the workspace's configured provider. Fine for dust_only; wants an explicit decision before ask_owner.

Also: don't flip the flag off mid-conversation. Anthropic is safe (the sanitizer strips the now-unreplayable blocks), but OpenAI replays web_search_call items unconditionally, and stripping them would re-expose the dropped-reasoning-item problem.

Rollback is flag-off, with one prompt-cache miss on the first request after the flip.

Deploy Plan

Nothing special — no migration, no config. Ship, then enable provider_native_web_search on a Dust workspace and run the verification in Tests/Risk before enabling anywhere else.

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.

1 participant