Web tools: URL reading + web search
Agents can read a web page the user links and search the web for current information — provider-agnostically, so the capability works on any configured model, not just those with a vendor-native web tool. Two tools: web_fetch (read a URL) and web_search (find sources). Web search is a swappable seam (WebSearch) with ≥2 implementations (Brave adapter + Null), so a missing key degrades gracefully instead of breaking. Both do network I/O in the bot process — not through the Executor — so the no-repo research agent uses them with no workspace.
- Code:
src/tools/web.ts(theWebSearchseam,BraveWebSearch+NullWebSearch,makeWebCapability, SSRF guardsassertUrlAllowed/ipInBlockedRange(sync literal guard, full IPv6 expansion) andmakeSsrfLookup(the undici connect-time IP-pinning guard),webFetchTool,webSearchTool);src/tools/workspace.ts(ToolContext.web,TOOLSETSwiring);src/agents/registry.ts(theresearchagent +RESEARCH_SYSTEM,generalprompt points web asks at it);src/core/dispatch/run.ts(webCapabilityinjectsmakeWebCapability(process.env)into the tool context). - Tests:
src/tools/web.test.ts.
Behavior
URL reading is provider-agnostic and in-process (Option B):
web_fetchfetches an http(s) URL and returns its text (HTML is stripped to readable text), running in the bot process via an injectedwebcapability — no Executor, no workspace. A no-repo agent can use it.web_fetch is SSRF-hardened, connect-time: only
http/httpsschemes; literal internal addresses are refused synchronously — including every IPv6 form of an internal address (loopback,10/8,172.16/12,192.168/16, link-local169.254/16incl. cloud metadata, ULA, and IPv4-mapped/-compat/NAT64 IPv6 like::ffff:169.254.169.254, expanded fully rather than string-matched) — and internal hostnames (localhost,*.local,*.internal). For hostnames, the production fetch uses an undici dispatcher whoselookupresolves and refuses internal IPs at connect time and connects to exactly that address — so the validated IP is the connected IP (no DNS-rebinding TOCTOU). Redirect targets are re-validated on every hop (sync guard) and re-connect through the same dispatcher. Responses are size-capped (~1 MB) and time-bounded (~12 s); blocks/timeouts/HTTP errors return a clear message, never a crash.- Binary links reach the model as vision/document input (M1b): when the response
content-type(parameters stripped, case-folded) isimage/jpeg|png|gif|webporapplication/pdf,web_fetchreturns a parts list — a text header plus animage/documentcontent part (base64) — instead of text, so the model sees the picture or reads the PDF. This is the second producer of image/document parts after Slack attachments (slack-channel.md), and the reasontool_result.contentisstring | ToolResultPart[](src/providers/types.ts). Per-file caps match the attachment path (5 MiB image, 10 MiB PDF); an over-cap binary is refused with a message (never truncated — a cut image is garbage), and otherimage/*types (e.g. SVG) are named as unsupported rather than sent to a model that would reject them. Text URLs are unchanged. Provider mapping: Anthropic carries text+image inside thetool_resultblock and hoists the PDF to a siblingdocumentblock after all tool results (SDK 0.39 types don't admit documents inside tool results; the API requires tool results to lead the turn); OpenAI-compatible endpoints get a stringrole:"tool"message plus the image in the following user message (PDF → the existing text placeholder). Run-visibility summaries render binary parts as[image image/png, N bytes]— the base64 never enters the stream.
- Binary links reach the model as vision/document input (M1b): when the response
web_search is a seam with ≥2 implementations:
WebSearchhas a realBraveWebSearchadapter (keyed byBRAVE_SEARCH_API_KEY) and aNullWebSearch.makeWebCapabilityselects Brave when the key is present, Null otherwise. With no key,web_searchreturns a clear "not configured" message (and notes URL reading still works) — it never breaks the run.Enablement by toolset:
web_fetch(URL reading) is in thefull(coding),readonly(review), andassistant(general) toolsets — broadly available;web_searchis gated to thewebtoolset, held by the research-capable agent.generalreads a linked URL but does not search (agent-general.md item 1).A dedicated
researchagent (toolset: "web",resources.repo: "none") answers research/URL questions with search + fetch and no workspace; since github-tools.md item 6 its toolset also holds the GitHub READ tools, so a question about one of our repos is answered from the repo over the App credential (a github.com URL of ours is read withgithub_file/github_tree, notweb_fetch), and its prompt forbids concluding a repo is inaccessible from a public-web 404.generaldirects web-research asks toagent:research.The capability is injected, not global: the dispatcher builds
webfromprocess.envper run; when absent (e.g., a run that didn't inject it), the tools report themselves unavailable rather than throwing.A page reaches the model in windows, never whole.
web_fetchREADS up to 1 MB (enough to strip a script-heavy HTML document to its text) but HANDS the model at mostMAX_FETCH_TEXT_CHARS(40,000 characters, ~10k tokens) per call. A longer page's header saysshowing characters <from>–<to> of <total>; pass offset=<to> to continue, and the tool takesoffset(a non-negative whole number; anything else is refused) to read the next window; the last window has no continue hint, an offset past the end says so with the page's length, and a page under the cap carries no paging note at all. A 1 MB read is still marked[truncated at 1 MB]. Before this, one ~1 MB page handed over whole was 307k tokens and killed a research run withprompt is too longbefore its second tool call. Behind every tool sits the runner's ceiling (run-loop.md item 13).
Validation criteria
| Criterion | Evidence |
|---|---|
A page is handed to the model at most MAX_FETCH_TEXT_CHARS at a time with a header naming the window and the next offset; offset pages through it (windows tile exactly), the last window has no continue hint, past-the-end and bad offsets are refused by name, a short page carries no paging note; the 1 MB read cap is still named | [unit] src/tools/web.test.ts::web_fetch tool::hands the model at most MAX_FETCH_TEXT_CHARS of a page and says how to read the rest…, ::offset pages through a long page; past the end says so; a bad offset is refused |
| web_fetch reads a public URL and returns its text; HTML is stripped | [unit] src/tools/web.test.ts::web_fetch tool::fetches a public URL and returns its text, ::strips HTML to readable text |
| SSRF: non-http(s) schemes and literal internal IPs/hosts (incl. IPv6-mapped) refused without fetching | [unit] ::web_fetch tool::refuses SSRF targets (incl. IPv6-mapped literals) without ever fetching; ::assertUrlAllowed::rejects non-http(s) schemes, ::rejects internal hosts and literal internal IPs (incl. IPv6-mapped); ::ipInBlockedRange::flags loopback/private/link-local/metadata/ULA (v4 + all IPv6 forms), ::allows public addresses |
| SSRF: DNS-rebinding closed by connect-time IP pinning (validated IP = connected IP) | [unit] ::makeSsrfLookup (connect-time SSRF guard)::*; ::web_fetch tool::surfaces a connect-time SSRF refusal (dispatcher guard) as a refusal |
| SSRF: redirect targets re-validated; internal redirect refused, public redirect followed | [unit] ::web_fetch tool::re-validates redirect targets and refuses an internal redirect, ::follows a redirect to an allowed URL |
Binary link → model-visible block: image/* URL → image part; application/pdf → document part named after the path; content-type normalized; text URL unchanged | [unit] src/tools/web.test.ts::web_fetch tool: binary links become model-visible blocks (M1b)::returns an image/* URL as an image content part the model can see, ::normalizes the content-type…, ::returns an application/pdf URL as a document content part…, ::a text URL still returns plain text… |
| Binary caps: over-cap image/PDF refused (not truncated); unsupported image types named, not sent | [unit] ::refuses an oversize image/PDF with a message instead of a truncated block, ::names an image type the model cannot consume instead of sending it |
| SSRF guards (literal, connect-time, redirect) sit in front of binary fetches | [unit] ::keeps SSRF guards in front of binary fetches (literal + connect-time + redirect) |
| Parts-array tool results reach each provider correctly (Anthropic: image in-block, PDF hoisted after all tool_results; OpenAI-compat: string tool message + hoisted image, PDF placeholder) and the runner passes them through with a text-only summary | [unit] src/providers/anthropic.test.ts::maps an array tool_result…, ::hoisted document blocks land after ALL tool_result blocks…, ::string tool_result content is unchanged…; src/providers/openaiCompat.test.ts::array tool_result…; src/runner.test.ts::tool results carrying non-text parts (M1b); src/tools/web.test.ts::toolResultText renders a parts array… |
Live: agent:research describes an image link / summarizes a PDF link end-to-end | [agent] In Slack: @switchboard agent:research what does https://<public>.png show? → the answer describes the picture; … summarize https://<public>.pdf → the answer cites the PDF's content. |
| Timeout/HTTP-error handled gracefully | [unit] ::web_fetch tool::reports a timeout gracefully, ::reports non-2xx status |
| web_search formats seam results; missing key degrades gracefully | [unit] ::web_search tool::formats results from the search seam, ::degrades gracefully when search is not configured, ::handles empty query and missing capability, ::reports a generic search failure |
| Brave adapter calls the API correctly and parses results; ≥2 impls selectable | [unit] ::BraveWebSearch adapter::calls the Brave API with the key header and parses results, ::throws on non-200; ::makeWebCapability::selects Brave when a key is present, Null otherwise, ::NullWebSearch throws WebSearchUnavailableError |
Enablement: web_fetch broad (incl. assistant), web_search gated to research; general holds assistant without web_search or bash | [unit] src/tools/web.test.ts::toolset + agent wiring::* |
Live: agent:research answers a question / summarizes a URL end-to-end | [agent] In Slack: @switchboard agent:research <a question needing current sources> → the answer cites sources found via web_search; … summarize <public URL> → the answer reflects that page's content. The web_search half requires BRAVE_SEARCH_API_KEY to be provisioned on the deployment; without it the run must still answer, saying search is not configured. |