feat: research web toolset — cited search and guardian-routed fetch - #5147
Open
daviddanialy wants to merge 5 commits into
Conversation
… fetch Part B of AIS-474: the research agent's platform toolset. Search rides OpenRouter's web-search plugin on the org's chat key, tagged mcp-research for distinct spend attribution, and returns only the plugin's cited results. Fetch goes through a dedicated guardian client with byte, redirect, and per-run budgets, reducing HTML to readable text. No assistant is granted the toolset by default; the research-agent runner attaches it explicitly, and both tools gate on the mcp_approval feature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
🦋 Changeset detectedLatest commit: 7423d2f The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
There was a problem hiding this comment.
cubic analysis
All reported issues were addressed across 18 files
Linked issue analysis
Linked issue: AIS-474: feat: research toolset — remaining deterministic sources into the dossier, web search + fetch for the agent
| Status | Acceptance criteria | Notes |
|---|---|---|
| ✅ | platform_web_search returns cited web results (title, URL, snippet) from OpenRouter's web-search plugin | SearchClient issues a CompletionRequest with WebSearch options, extracts url_citation annotations into {title,url,snippet}, and tests assert the returned results. |
| ✅ | Search calls are billed to the org chat key with distinct research usage source ('mcp-research') | CompletionRequest sets UsageSource to ModelUsageSourceMCPResearch; tests assert KeyType and UsageSource on the fake completion request. |
| ✅ | platform_fetch_page uses a guardian-routed client and enforces fetch bounds: 2 MiB read cap, 40k-char text cap (truncate w/ flag), 5-redirect bound, refuse binary content, reduce HTML to readable text | Fetch tool enforces media-type checks, reads up to maxFetchBytes then truncates, extracts HTML via streaming tokenizer, clips to maxContentChars and sets Truncated; redirect bound and scheme checks applied; tests cover extraction, truncation, binary refusal, redirect bound. |
| ✅ | Per-run fetch budget: 25 fetches per assistant chat id with isolation and windowing | fetchBudget enforces maxFetchesPerChat keyed on GramChatID and tests verify budget exhaustion and isolation per chat id. |
| Packaging/wiring: new 'research' platform toolset and tools gated on 'mcp_approval' feature, research tools wired into runtime | Toolset slug ResearchToolsetSlug and NewResearchToolset are added; ResearchTools() returns the two tools with RequiredFeature 'mcp_approval' and runtime wiring passes ResearchTools into dependencies. The PR does not explicitly show the public endpoint path being registered or an explicit default-no-grant assistant configuration in this diff (the slug and gating are present, but the 'no assistant is granted by default' policy is not proven here). | |
| ✅ | OpenRouter plumbing: CompletionRequest.WebSearch → outbound plugins: [{id:'web'}], and response url_citation annotations survive decoding | CompletionRequest gained WebSearch options; unified client initializes RequestPlugin when WebSearch is set; ResponseChoice unmarshals raw message to lift annotations; GetCompletion returns Annotations. Tests rely on and assert annotations. |
| ✅ | Tool descriptors and docs carry the untrusted-content posture (results are data to weigh and cite, not instructions) | Tool descriptors for platform_web_search and platform_fetch_page include explicit untrusted-content guidance; the changeset and tool descriptions state this posture. |
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
…r-deterministic-sources' into daviddanialy/ais-474-feat-research-web-tools
This was referenced Aug 11, 2026
…' into daviddanialy/ais-474-feat-research-web-tools
Nine review findings on this branch, most of them the same shape: a tool that reaches the open web on an organization's money was trusting something it should not have. - The research toolset was served to any assistant token in any mcp_approval organization. Nothing reaches these tools over HTTP — the runner holds them in-process — so the entrypoint now refuses the slug outright. What it was handing out is billable search plus arbitrary page fetch. - Searches had no per-run budget while fetches did, though a search is the billed half and the loop picks its next query from the last one's results. Both budgets are now the same bounded counter. - Pages were fetchable over plaintext http. This tool follows links about a party under review and what it returns becomes evidence an admin decides on; over http, anyone on the path chooses what that evidence says. https only. - A response that declared no content type was waved through, so a binary arrived as bytes the agent would try to quote. An undeclared body is now sniffed, and text with no header still fetches — plenty of small sites omit it. - Whitespace was collapsed in every body, including JSON, where it is structure the agent may be reading rather than layout. - The streaming path accepted a web-search request and dropped every citation, since its reader never parses annotations. It refuses now: a search whose citations are gone is unsourced text. - A present-but-nil auth context panicked instead of refusing. - The result loop bounded with == , so a non-positive cap returned every citation rather than none. - The 2 MiB byte cap had no test: the existing one hits the character clip at 40k and never reaches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
Three follow-ups from review of the research tool bounds. - The https-only check covered the first hop and nothing after it, so a page could answer over TLS and redirect the fetcher to http — content chosen by whoever is on the path, returned as evidence. The redirect policy now refuses any hop that is not https. - The per-run budgets keyed on a chat id the caller supplies, and an absent one shared a single bucket across every caller that omitted it, which is not a per-run budget. A call that cannot say which run it belongs to is refused. Rotating the id is not reachable: the platform entrypoint no longer serves these tools, and the runner sets the key to the report id. - A search cap of zero returned one result, because the bound was tested after the append. The schema and description also still advertised http URLs the tool now refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part B of AIS-474 (as re-scoped): the research agent's own tools — the two things that cannot be deterministic — packaged as the
researchplatform toolset. Part A (deterministic sources into the dossier) is the base PR #5144.Tools
platform_web_search— runs the query through OpenRouter's web-search plugin on a cheap allowlisted model with reasoning off; the model's prose is discarded and only the plugin'surl_citationannotations return, as{title, url, snippet}. Billed to the org's chat key under a new unregisteredmcp-researchusage source, so research spend is distinctly attributable (the AIS-476 decision) and can never become a BYOK slot.platform_fetch_page— fetches one public http(s) page through a dedicated guardian client (SSRF control on every hop): 2 MiB read cap and 40k-char text cap that truncate with a flag rather than fail, 5-redirect bound, binary content refused, HTML reduced to readable text with a streaming tokenizer. A 25-fetch per-run budget keyed on the assistant chat id keeps a seeded result chain from turning one run into a crawl.Both tool descriptions carry the untrusted-content posture: results are data to weigh and cite, never instructions.
Wiring
researchplatform toolset slug, served at/platform/mcp/research. No assistant is granted it by default — the AIS-475 research runner attaches it explicitly — and both tools gate on themcp_approvalfeature.CompletionRequest.WebSearch→plugins: [{id: "web"}]outbound; responseurl_citationannotations now survive decoding (the SDK union type drops them, soResponseChoicelifts them from the raw message) ontoCompletionResponse.Annotations.Tests
11 new research-package tests (citation extraction and filtering, clamping, chat-key + attribution assertions, HTML stripping, truncation, redirect bound, scheme/content-type refusal, budget exhaustion and isolation) plus green suites across every touched package (2,222 tests) and clean lint.
Known gap: the live OpenRouter plugin path is exercised against its documented contract via fakes; first real end-to-end call happens when the AIS-475 runner lands.
Stacked on #5144; merges with the stack.
🤖 Generated with Claude Code
Summary by cubic
Introduces the
researchplatform toolset — cited web search and guardian-routed page fetch — to give the research agent controlled, attributable web access. Previously the toolset was reachable over HTTP; now theresearchslug is refused (404) and used only in-process by the research runner. Adds per-run budgets that require a run id and makes fetch https-only across every redirect hop.platform_web_search: runs OpenRouter’s web-search plugin on a low-cost allowlisted model with reasoning off; returns only {title, url, snippet}; clampsmax_resultsto 1–10 (default 5; zero returns none); 15-search per-run budget keyed by assistant chat id; requires auth context and a run id; billed via the org chat key with usage sourcemcp-research; streaming path is refused to avoid dropping citations.platform_fetch_page: fetches via a dedicated guardian client with SSRF control on every hop; https-only (each redirect must stay on https); 2 MiB read cap; 40k-char text cap; max 5 redirects; rejects non-text (sniffs type if header is absent); collapses whitespace only for HTML; setstruncatedwhen clipped; 25-fetch per-run budget keyed by assistant chat id; requires a run id.research, not granted by default and gated onmcp_approval; the HTTP entrypoint rejects this slug; runtime wires viaResearchToolswith a dedicated, bounded guardian client.openrouterplumbing: addsWebSearchOptions(plugins: [{id: "web"}]withmax_results) and decodesurl_citationannotations viaResponseChoice; streaming rejects requests withWebSearch; all other completion paths now explicitly setWebSearch: nil.Written for commit 7423d2f. Summary will update on new commits.