Skip to content

feat: research web toolset — cited search and guardian-routed fetch - #5147

Open
daviddanialy wants to merge 5 commits into
daviddanialy/ais-474-feat-dossier-deterministic-sourcesfrom
daviddanialy/ais-474-feat-research-web-tools
Open

feat: research web toolset — cited search and guardian-routed fetch#5147
daviddanialy wants to merge 5 commits into
daviddanialy/ais-474-feat-dossier-deterministic-sourcesfrom
daviddanialy/ais-474-feat-research-web-tools

Conversation

@daviddanialy

@daviddanialy daviddanialy commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Part B of AIS-474 (as re-scoped): the research agent's own tools — the two things that cannot be deterministic — packaged as the research platform 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's url_citation annotations return, as {title, url, snippet}. Billed to the org's chat key under a new unregistered mcp-research usage 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

  • New research platform 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 the mcp_approval feature.
  • OpenRouter plumbing: CompletionRequest.WebSearchplugins: [{id: "web"}] outbound; response url_citation annotations now survive decoding (the SDK union type drops them, so ResponseChoice lifts them from the raw message) onto CompletionResponse.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 research platform 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 the research slug 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}; clamps max_results to 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 source mcp-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; sets truncated when clipped; 25-fetch per-run budget keyed by assistant chat id; requires a run id.
  • Toolset wiring: new MCP toolset slug research, not granted by default and gated on mcp_approval; the HTTP entrypoint rejects this slug; runtime wires via ResearchTools with a dedicated, bounded guardian client.
  • openrouter plumbing: adds WebSearchOptions (plugins: [{id: "web"}] with max_results) and decodes url_citation annotations via ResponseChoice; streaming rejects requests with WebSearch; all other completion paths now explicitly set WebSearch: nil.

Written for commit 7423d2f. Summary will update on new commits.

Review in cubic

… 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>
@daviddanialy
daviddanialy requested a review from a team as a code owner August 11, 2026 21:49
@linear-code

linear-code Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

AIS-474

@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7423d2f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
server Minor

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

@cubic-dev-ai cubic-dev-ai Bot 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.

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

Comment thread server/internal/platformtools/runtime/service.go
Comment thread server/cmd/gram/start.go
Comment thread server/internal/platformtools/research/research.go
Comment thread server/internal/platformtools/research/tool_web_search.go Outdated
Comment thread server/internal/thirdparty/openrouter/unified_client.go
Comment thread server/internal/platformtools/research/tool_fetch_page.go Outdated
Comment thread server/internal/platformtools/research/tool_fetch_page.go
Comment thread server/internal/platformtools/research/research.go Outdated
Comment thread server/internal/platformtools/research/tools_test.go
…r-deterministic-sources' into daviddanialy/ais-474-feat-research-web-tools
daviddanialy and others added 2 commits August 12, 2026 14:15
…' 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>

@cubic-dev-ai cubic-dev-ai Bot 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.

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

Comment thread server/internal/platformtools/research/tool_web_search.go
Comment thread server/internal/platformtools/research/tool_fetch_page.go
Comment thread server/internal/platformtools/research/research.go
Comment thread server/internal/platformtools/research/tool_fetch_page.go
Comment thread server/internal/platformtools/research/research.go
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>
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